Repository navigation
fix: decouple wallet restore from profile refresh - #1686
ben-kaufman wants to merge 35 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
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
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the terminal-WALLET and native-authentication Reset findings remain open.
Findings:
- HIGH — Terminal identity failure still blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1061still requires a usable identity export before capturing ordinary reservation state. At this head, a new watch-only account reservation remained pending and remote WALLET bytes/version stayed unchanged until the original identity credentials were repaired. This repeats the existing finding. - MEDIUM — Reset still waits for unbounded native authentication.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138still drains admitted work insideNonCancellablewith VSS0.5.26-rc2. The existing thread remains open; the installed-app result is below.
No new verified PR defect in cd1d129..2dc86ae. The integration keeps the original backup owner throughout restore and applies the invoice guard from bitkit-android#1858 fix: stop a restored wallet paying a spark-paid invoice again.
QA for 2dc86ae:
Built 2dc86ae and drove an isolated, unfunded API 36 ARM64 dev wallet with staging services. Unpublished Debug controls stage credential faults, restore timestamps and response holds; the restore, SDK parsing/preparation and VSS results are real. Native libraries match the pristine head APK. Recordings show the app; hidden state and timing are checked in logs/readback.
Complete a wallet restore with the original backup owner ✅ Full VSS restore retained the original owner and reopened address allocation, checked in logs/readback.
Actual full VSS restore completed, persisted the restore-time invoice guard, retained original VSS-owner admission, cleared required markers, and allowed a new ordinary address. Checked in logs/readback. No phrase re-entry or nonempty USDT import in this case.
restore-ownership-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Refuse an unlisted pre-restore invoice over Spark ✅ Spark refused an unlisted pre-restore invoice in all three controlled history states, checked in logs/readback.
Real native LDK invoice and Spark preparation; controlled restore timestamps make it pre-restore. Spark refuses it before admission with history unseeded, seeded-but-empty, and injected unreadable listing. Payment list, send-record count and stored hash/uncertainty remain unchanged, checked in logs/readback.
restore-invoice-guard-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Back up ordinary changes after terminal identity failure ❌ New ordinary reservation state stayed out of WALLET until identity repair, checked by VSS readback.
With saved identity credentials unusable, a newer ordinary watch-only account reservation stayed pending and remote WALLET bytes/version stayed unchanged. Restoring the original credentials allowed a complete upload; real SDK/VSS checked in logs/readback.
terminal-wallet-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Finish Reset while native authentication is held ❌ Reset remained wiping while real authentication was held, checked in video and timed readback.
The real standalone LDK authentication response stayed held for 229.2 seconds. UI Reset remained wiping with credentials present, admission closed and no retryable error for at least 203.8 seconds. After release, the old lookup cancelled and Reset removed credentials and reopened admission 13.5 seconds later. Timing/state checked in logs/readback; consecutive recordings joined in order.
stalled-native-auth-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Check: pristine build and affected regressions ✅ Assembly and the affected repository suites passed.
assembleDevDebug and complete BackupRepoTest, LightningRepoTest, SparkRepoTest, SparkServiceTest, SendSheetSyncOverlayTest and PaymentFailureReasonExtTest passed on unmodified head source. These are component checks with mocked service resources. Full-suite and lint gates were not repeated.
Retained passing evidence, with its original scope and heads:
- The cd1d129 report covers profile reconnect, held-read sign-out, real legacy-session refresh/wipe and same-key avatar replacement. Their production source and native dependency pins are unchanged.
- The cd0befc report covers unchanged Spark notification replay, interrupted debounce, storage-error recovery and a real held SETTINGS PUT across wipe/replacement.
- The acdd99c report covers unchanged explicit restore-gate clear and real timeout wakeup.
- The 710292aa report covers required wallet/USDT failure gates, the post-restore pause, allowance admission/off-on and scheduled Settings status.
- The upstream device report covers node-side restored-invoice refusal, a new invoice during a Spark outage, stopped-node Spark payment and relaunch protection at
cb9a7af06. Its delta review covers the later changes; the final Spark-side refusal is driven above.
Coverage: contiguous cd1d1290784d62ef274110529b6242d907a6863e..2dc86ae0b6e6c85570c8bf6becd6f65ff06d27f1, all sixteen delta paths, reached restore/payment paths and inherited open findings. Imported Spark UI matches the pinned base; it is not a new PR finding. This run does not establish a funded Spark settlement, a full-withdrawal stale-sync journey, nonempty USDT import, phrase re-entry, or a payment-result overlay reproduction. Those limits do not turn the retained reports into fresh executions.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 retains matching deferred identity recovery and the terminal-WALLET limitation. The imported Spark payment integration is separate from that twin; source comparison only.
Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — one new cache-loading regression; the terminal-WALLET and native-authentication Reset findings remain open.
Reaudit: diff, 10 files.
Findings:
- HIGH — Terminal identity failure still blocks newer ordinary WALLET backups.
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1061still requires usable identity export before capturing ordinary reservation state. The current-head real SDK/VSS case left a new watch-only reservation pending and remote WALLET bytes/version unchanged until credential repair. This repeats the existing finding. - MEDIUM — Node startup blocks cached wallet data.
app/src/main/java/to/bitkit/repositories/WalletRepo.kt:466holds the shared balance mutex during a potentially minute-long node wait. Cache hydration waits behind it, withholding balances and receiving details. The component regression fails, its causal control passes, and the installed cold-start control below reproduces the zero-balance display. - MEDIUM — Reset still waits for unbounded native authentication.
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138still drains admitted work insideNonCancellablewith VSS0.5.26-rc2. The existing thread remains open; the current-head installed-app result is below.
QA for ff9275d:
Built the pinned head and drove an isolated unfunded Android 16 / API 36 ARM64 dev wallet with staging services. Unpublished Debug controls stage credential faults, Spark-state emissions, result navigation, and operation holds. Native libraries match the pristine head APK. Controlled balances are fixtures, and no payment was submitted or settled.
Back up ordinary changes after terminal identity failure ❌ A newer ordinary watch-only reservation stayed pending and remote WALLET bytes/version stayed unchanged until original identity credentials were repaired. Real SDK/VSS readback.
The original credentials were repaired afterward and a fresh complete WALLET upload succeeded. Hidden reservation and remote-record equality are checked in SDK/VSS readback; the recording shows the app during the case.
terminal-wallet-2x.2x.mp4
Refresh Spark balances while the node is stopped ✅ Four controlled Spark balance/deposit emissions updated Home; live totals, send limits and persisted-cache equality with the node stopped were checked in readback, including after cache hydration.
The real Spark refresh mutex isolates the four controlled emissions from live zero-balance refreshes. Home is visible in the recording; live totals, send limits and persisted-cache equality are checked in readback. This is not a funded Spark settlement.
spark-balance-2x.2x.mp4
Keep payment results visible after Spark balance falls ✅ Controlled Pending, Success and Error routes stayed visible after Spark balance fell to zero; no Connecting overlay appeared, and stopped-node/zero-balance state was checked in readback.
Debug controls select the real Pending, Success and Error destinations and drop the controlled Spark balance to zero. The recording shows each result without the Connecting overlay. This tests result presentation, not an actual payment outcome.
payment-results-2x.2x.mp4
Read cached balances and receiving details during node startup ❌ On a real cold start, Home stayed at zero for at least 78.5 seconds after cache loading was released; the staged 50,000-sat cache remained readable and real node startup remained held, checked in readback.
Debug holds choose the cold-start ordering, and a persisted 50,000-sat amount stages the display cache; it is not wallet funds. The actual node-start balance wait reaches its deadline. More than one refresh queued, so this result does not claim recovery after the first deadline. The focused component regression separately demonstrates withheld cached address/invoice data and passes with the one-line causal control.
startup-cache-2x.2x.mp4
Finish Reset while native authentication is held ❌ Real auth stayed held for 232.9 seconds; Reset remained wiping with credentials present and admission closed for at least 203.7 seconds, checked in timed readback.
The auth proxy holds and then forwards a genuine standalone LDK authentication response. Reset is the actual Settings action. Timing, credential presence and admission state are checked in readback; consecutive recording segments are joined in order.
stalled-native-auth-2x.2x.mp4
Check: pristine build and affected regressions ✅ Assembly and the affected repository suites passed.
assembleDevDebug and the complete WalletRepoTest, BackupRepoTest, LightningRepoTest, WalletViewModelTest, SendSheetNavigationTest, SendSheetSyncOverlayTest and DeriveBalanceStateUseCaseTest passed on unmodified head source. A separate desired-behavior cache-hydration regression fails; its one-line causal control passes. These are component checks with mocked service resources. Full-suite and lint gates were not repeated.
Retained passing evidence, with its original scope and heads:
- The 2dc86ae report covers original-owner full VSS restore and Spark refusal of unlisted pre-restore invoices. The current delta does not change those restore/import or invoice-guard decisions; its new balance/cache behavior is tested separately above.
- The cd1d129 report covers unchanged profile reconnect, held-read sign-out, real legacy-session refresh/wipe and same-key avatar replacement.
- The cd0befc report covers unchanged Spark notification replay, interrupted debounce, storage-error recovery and a real held SETTINGS PUT across wipe/replacement (Manual 3 and the interrupted portion of Manual 4).
- The acdd99c report covers unchanged explicit restore-gate clear and real ten-minute expiry (Manual 4).
- The 710292aa report covers required wallet/USDT failure gates, the post-restore pause, allowance admission/off-on (Manual 1/2 and J2), and scheduled Settings status (J3). Its funded LDK routing evidence retains its original native pin.
Reviewed the contiguous 2dc86ae..ff9275d delta, all 10 changed paths, reached startup/payment paths and inherited open findings. No planned current-head case was left unrun. This run does not establish funded Spark settlement, nonempty USDT import or a fresh complete seven-action J1 execution; retained reports keep their stated limits. Boltz collector cancellation, native/hardware limit races and unknown legacy cache splits have component coverage, without separate device journeys.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 retains matching deferred identity recovery and the terminal-WALLET limitation. This Android Spark balance/result delta has no matching change in that twin; source comparison only.
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up of 89f3bc6b since ff9275d6, using baseline bc-7f217393-7b1e-4f77-8eee-b0a99d184517 at merge base 6bb0ff55. This pass covered cached wallet publication during balance derivation. The other baseline surfaces are inherited; the pull-request file set changed only in WalletRepo, WalletRepoTest, and the changelog fragment.
No new actionable code findings.
syncBalances derives under balanceSyncMutex and holds balanceMutex only while it commits. loadFromCache can publish the stored balance, address, and invoice during that derivation, including while the node is still starting and getBalancesAsync is waiting to run. A second syncBalances stays queued until the first commit, so the later result remains the visible and cached balance. Hydration during an already-publishing write waits on balanceMutex. Stopped-node Spark updates and VSS cancellation of an in-flight cache write use the same admission and publication paths as the baseline. On iOS 0ad55417, spending and receive values persist in AppStorage and stay visible when a node balance read is absent; this Android change matches that startup visibility. That comparison is limited to this flow in bitkit-ios#1121.
Validation: inspected the delta, syncBalances and loadFromCache callers, and the balance cases in WalletRepoTest.kt, including cached wallet data loads during serialized balance reads and balance writes and cache hydration cannot overtake a pending update. Those tests were not executed.
Device testing: not performed in this review.
Suggested additional test cases
- Android. Existing wallet with a cached on-chain balance and receive invoice, Lightning node not running at process start. Cold-start the app and open Home, Spending, and receive before the node reports running. The cached balance, address, and invoice appear during startup. After derivation finishes, the live balance replaces them, and a Spark or sync update that completes afterward stays visible.
Earlier findings reconciled at this revision: 3 still present, 1 verified fixed.
Findings
- [MEDIUM] Bound native authentication before draining VSS during reset — now at
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138. Still present from the earlier review. - [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — now at
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1062. Still present from the earlier review.
Existing concerns
- [LOW] Allowance journey describes a delayed-contact check its actions do not perform — still unresolved; already tracked.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the startup cache regression is fixed; two inherited defects still reproduce.
Reaudit: diff, 3 files. No new findings in this delta.
Findings:
- HIGH: Terminal identity failure blocks newer ordinary WALLET backups. At
BackupRepo.kt:1061, identity export must succeed before ordinary wallet state is captured. With an invalid saved session and no stored identity secret, a new watch-only reservation stayed pending and the remote WALLET bytes/version did not advance. A later restore therefore loses those newer ordinary changes. Repairing the original credentials allowed a complete upload. This remains the finding recorded in the previous review. - MEDIUM: Reset waits for unbounded native authentication. At
VssClientCoordinator.kt:138,NonCancellabledrain joins native work whose authentication has no deadline. Settings Reset remained loading with credentials present and admission closed after 191.3 seconds. It finished only after the held native auth response was released. The native transport needs a bound or effective cancellation so this existing finding cannot leave Reset waiting indefinitely.
QA for 89f3bc6:
Tested on an Android 16 / API 36 ARM64 emulator. I built the pristine head, then used an unpublished Debug build with timing/state controls and local regtest endpoints. Its native libraries are byte-identical to the pristine APK. All funds were regtest funds. Videos play at 2× with changed frames retained.
Read cached balances and receiving details during node startup ✅ Home, Spending and the cached Savings QR appeared before startup finished; readbacks confirmed cache contents and later fresh balances.
I held real native startup and cache loading separately, waited for the balance read to block, then released cache loading. Cached Home total 70,000 sats and Spending 20,000 sats published within 1.07 seconds while the node was still starting. These amounts were display fixtures. The cached Savings address produced a usable receive QR before startup finished.
Readbacks confirmed that address, invoice and BIP21 matched persisted cache. Auto/Spending receive stayed behind the existing refresh/node gates; this does not prove a usable Lightning invoice UI before startup. After startup release, real zero balances replaced the fixture in live state and cache. The pristine WalletRepoTest class also passed the new subsequent-refresh ordering regression.
startup-cache-ui-2x.2x.mp4
Home, Spending and Savings receive during held startup, followed by release. The opening error toast comes from the Debug launch URI used by the harness.
Pay with Spark while the node is stopped ✅ Two real payments retained their settled result actions; native readbacks and recipient acknowledgements confirmed balances and full withdrawal.
After receiving 100,000 sats through a real Spark invoice, I stopped LDK and paid fresh 1,000-sat and 60,000-sat invoices through the app. The recipient acknowledged both payments. Both settled Bitcoin Sent screens kept Close and Details, and Details opened the receipts. The second payment left less balance than the amount paid. Connecting appeared during submission before the settled result.
Native readbacks, Home and persisted cache agreed on 100,000 → 98,994 → 38,752 → 0 sats, with LDK stopped. A full withdrawal through the real repository/native Spark path used the accepted 10,701-sat quote and delivered 28,051 sats on-chain, confirmed at regtest height 231. Savings and LDK contributions were zero in this funded run.
funded-spark-2x.2x.mp4
Funding, both UI payments and receipts, then the real full withdrawal. Native balances, recipient acknowledgements and chain confirmation were checked separately from the screen recording.
Refresh Spark balances while the node is stopped ✅ Controlled Spark emissions updated Home and cache without hydration restoring an older amount.
With LDK stopped, I emitted four amount/deposit states under the real Spark refresh mutex. Live totals, Home and persisted cache followed each change; hydration preserved the newer state. This isolates app publication ordering; the funded case above covers actual Spark settlement.
spark-balance-2x.2x.mp4
Controlled app-state emissions, with native LDK state and persisted cache checked by readback.
Reject spending a retained Spark display balance ✅ A controlled retained display stayed visible, while readbacks confirmed zero sendable funds and refused payment admission.
After the real withdrawal left native funds at zero, I staged a 5,000-sat retained display amount. Home showed it, but the sendable limit remained zero. A fresh 1,000-sat invoice had no Swipe To Pay; direct production repository admission returned NodeNotRunningError, with native payment history unchanged. This tests the retained-state guard; I did not induce an actual failed native balance read.
display-only-spark-2x.2x.mp4
Display-only balance and payment UI; sendable limit, admission refusal and unchanged native history were checked by readback.
Back up ordinary changes after terminal identity failure ❌ Real remote WALLET bytes/version stayed unchanged while a newer ordinary reservation remained pending.
I invalidated a disposable profile session, removed its stored identity secret and created a new ordinary watch-only reservation. Real identity export refused and ordinary backup could not advance. Restoring the original credentials let the complete upload succeed. This reproduces the inherited HIGH finding without replacing the native backup implementation.
terminal-wallet-2x.2x.mp4
The app shows terminal profile failure. Reservation state, unchanged remote bytes/version and the repaired upload were checked through repository/native readbacks.
Finish Reset while native authentication is held ❌ Reset stayed loading; readbacks still showed credentials present and admission closed after 191.3 seconds.
I held the genuine standalone LDK VSS authentication response for 220.3 seconds and invoked Settings Reset. Cancellation did not release the native operation while the response was held. After release, native lookup cancelled and Reset removed credentials and reopened admission 20.1 seconds later. This reproduces the inherited MEDIUM finding with the real pinned VSS native library.
stalled-native-auth-2x.2x.mp4
Reset remains loading across the hold, then completes after release. Credential/admission state and elapsed durations were checked separately by readback and proxy timing.
Check: pristine build and regressions — assembleDevDebug and the seven affected full unit-test classes passed. Full-suite, lint and Compose instrumentation were not rerun in this pass.
Earlier passing cases were retained because their relevant implementation paths are unchanged:
- Previous-head report: controlled Pending/Success/Error send presentation.
- Original-owner restore report: full VSS restore and refusal of an unlisted old Spark invoice.
- Profile lifecycle report: reconnect/sign-out, real legacy refresh/wipe and same-key avatar guard.
- Replay and cancellation report: Spark replay, interrupted debounce, status-error recovery and an actual SETTINGS PUT held across wipe.
- Restore retry report: restore clearing and real ten-minute expiry.
- Admission and allowance report: required wallet/USDT gates, paused restore, allowance journeys and scheduled payments.
The previously deferred allowance-documentation concern remains tracked in bitkit-android#1766.
Pair PR: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad5541 matches deferred identity restore, complete-backup refusal and VSS draining in source. iOS persists displayed balances separately; the Android mutex change has no matching iOS diff. This was a source comparison, with no new iOS device run.
Coverage: the contiguous delta ff9275d…89f3bc6, all three changed files and the remaining open findings. The new startup path and stopped-node balance publication were driven; overlapping derivation ordering and cancellation during cache publication also rely on the focused unit regressions.
Reviewed by gpt-6.1-sol-high
jvsena42
left a comment
There was a problem hiding this comment.
Re-review of the three own commits since a2c58f242 (head 89f3bc6bf): one LOW inline, nothing above LOW.
Checked and clean:
- Spark backups after restore (
acdd99c30): only re-evaluates the SPARK_EXIT status and the Spark listener whenrestoreInProgressflips. Uploads still go throughshouldBackup;scheduleBackupruns insidevssClients.withWallet(owner);uploadSparkExitBackupmerges the remote object before the first put, and that flag resets inSparkRepo.wipe. A failed or skipped restore can't overwrite stored exit data with a fresh wallet's state. - Wipe vs balance writes (
89f3bc6bf):syncBalancesandloadFromCacheare admittedwithWalletjobs, sowithSuspendedClientscancels and joins them beforecacheStore.reset()/resetState(). Both long-lived collectors survive the newCancellationException. Lock order isreceiveStateMutex → balanceMutexandbalanceSyncMutex → balanceMutexwith no reverse path. - Cached balance vs sends: hydration stops once
hasLiveBalanceis set; with the node stoppedmaxSendLightningSatsis the livespark.sendableSats. No path lets more leave than exists. - Result screens (
ff9275d6d):waitsForNodeis false on Pending/Success/Error, so it only stops the sync overlay covering a result; payment state and navigation are untouched. - Old caches decode the new nullable
BalanceStatefields as null and the null case never guesses a split.
ff9275d6d and 89f3bc6bf are Spark balance and startup-cache work rather than restore/profile decoupling. They build on this PR's withWallet/restoreInProgress machinery, so I reviewed them here.
The LOW can be fixed here or moved to a follow-up issue, your choice; please say which on the thread.
Device gate (partial): 89f3bc6bf — not run yet; pending: cold start with a cached balance while the node starts, a Spark payment's result screen with the node stopped, Backup settings after a restore. Device results and the final review type follow.
Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
|
Device gate (partial): Device: Gate_Throwaway AVD (emulator-5554), Android, fresh dev wallet funded 300 000 sats on-chain via staging Blocktank deposit (no Spark funds; see check 2). Video: launch -> Home balance "300 000" visible
App log: seconds after launch (proc = log session init, home = "User entered home screen", node = "Node started")See table in cold/ timings (all runs). App-side process->home: PR 1.6, 4.9, 2.3, 13.1, 1.7, 1.8 s (median 2.0); master 4.6, 9.8, 5.2, 2.2, 10.0, 10.2, 8.7, 12.3 s (median 9.2). Comparable-load runs: PR 1.6/2.3/1.8 vs master 4.6/5.2/2.2. Balance shown while node still starting (PR)Every PR run: balance visible before "Node started": 3.1 vs 11.0 s, 9.9 vs 17.7, 4.3 vs 9.8, 8.6 vs 16.5, 3.4 vs 9.9, 17.9 vs >25 (recording ended). Log at that time: Receive addressPR run1: Receive tapped 4 s after launch; savings-only QR (cached bcrt1 address, Savings/Spending tabs) shown ~4.4 s after launch, node started at 11.0 s. Then the sheet switches to the Auto tab with a spinner placeholder until the unified QR (address + Spark invoice) appears ~12.5 s. Master run4 shows the same sequence (savings QR at ~5.4 s, spinner, unified QR after node start at 11.5 s), so the address-then-spinner flip is pre-existing, not introduced. Result: PASS. cold start -> balance: 3.1-4.3 s on the PR under low load (median 6.5 s across all runs) vs master 4.3 s low-load (median 8.8 s). The difference is inside the load noise; no regression, and the balance and cached receive address appear before the node finishes starting. Still running: Spark payment result screen, restore → Backup settings, |
|
Device gate (partial): Wallet: dev regtest, 300 000 sats savings (staging Electrum), 0 spending (no Spark funds; see check 2). Run 1 (in-app Reset wallet -> Restore, 10:07 UTC): balances right (Savings 300 000, Spending 0, 1 activity restored). Master baseline (6bb0ff5 CI APK, same seed, WALLET null): observers start after restore (10:16:05), Rerun 2 (PR, same seed, pm clear + restore; master had by then uploaded a WALLET object so WALLET restored): observers after restore, recovered (METADATA ok 10:19:21). Result: PASS on reruns 2 and 3; FAIL on run 1 (intermittent, 1 of 3 PR restores; 0 of 1 master restores). Likely cause: observers started Log excerptsTraced to a race that is already on master, not introduced here; details and reproduction in #1686 (comment). |
|
Outside this PR's scope; no change needed here. It came up in the device gate's restore check and I traced it to master. Backups that become required during a restore are never scheduled when the restore ends (master Scenario: in-app Reset wallet → Restore, where the node reaches Running before the restore finishes.
Seen in 1 of 3 restores on the emulator. Changes made in that window aren't uploaded until it recovers. This PR fixes it for SPARK_EXIT only (acdd99c). The one-line fix is to apply the same Reproduction (fails on master
|
jvsena42
left a comment
There was a problem hiding this comment.
Device gate result. My LOW at WalletRepo.kt:473 stays open; nothing above LOW.
Device gate: 89f3bc6bf — Gate_Throwaway emulator, PR build against master's CI APK (6bb0ff550), dev regtest on staging Electrum; Spark checks on the bitkit-docker spark profile with a setup-only patch. perf: cold start → balance 3.1–4.3 s at low load (master 4.3 s), medians 6.5 s vs 8.8 s on a loaded host, so no regression. l10n: no UI text changed.
- Cold start: the cached balance shows before "Node started" on every run (e.g. 3.1 s vs 11.0 s), and the cached savings receive QR shows at about 4.4 s. The QR switching to a spinner until the unified invoice is ready is the same on master.
- Spark payment with the node stopped (Electrum unreachable, node
ErrorStarting):- A 40 000-sat payment shows "Bitcoin Sent" with no sync overlay for 30 s, and Home updates to 19 838.
- With the Spark operators down, a 15 000-sat payment shows "Payment Pending" with no overlay for 50 s+.
- Wipe → restore: balances and SPARK_EXIT backups were correct in all 3 runs. In 1 run, METADATA stayed failing; that traces to a race already on master, in #1686 (comment).
amount-limits/send-spending-after-receive.xml: 11/11 pass, adapted to a Spark balance instead of an LND channel.- No
ANR in,FATALorOutOfMemoryErrorin logcat.
backup-restore/pending-wallet-recovery.xmlneeds VSS and Pubky-cache fault controls the journey says aren't provisioned.- The Error result screen.
- A master baseline for the result-screen overlay.
Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
|
@ben-kaufman this conflicts with |
|
@ben-kaufman please resolve conflicts. |
jvsena42
left a comment
There was a problem hiding this comment.
Re-review of the merges since my approval (head 4aa622fc4): one LOW inline from the conflict resolution in 55694dd, nothing above LOW, approving.
- 55694dd (Spark exit balances):
- A successful derive matches master's exit accounting (
shownSpendingSats + outsideExitSats, with in-transfer addinggetSparkExitSats), and the PR's nullable aggregates sit on top with no double count. - During an exit,
sendableSatsis 0, so the fallback's max send never reachesensureNotExiting. - The collector re-syncs on
isReady,isExitingand exit-state changes, keeping both parents' triggers.
- A successful derive matches master's exit accounting (
- 4aa622f (profile): #1884's install-time tags flag and epoch under
profileStateLockare intact, and so are this PR'srequireCurrent(signIn)guards andrestoreBase. The 54 removed lines are superseded by #1884's install path,markTagsRead,writeProfileCopyandLoadedProfile. - My earlier
NodeRunTimeoutErrorLOW is fixed in b2c3ea6 and survived the merge; I resolved that thread.
The LOW can be fixed here or moved to a follow-up issue, your choice; please say which on the thread.
Device gate: 89f3bc6bf results stand for the restore paths. The exit balance display after a merge is unit-covered; a device check would need a finished exit with the node down. perf: the collector also fires on exit-state changes, each syncBalances serialized on balanceSyncMutex.
Reviewer model: Claude Fable 5.2 (claude-fable-5-2), Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: not exposed
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — terminal-WALLET availability and held-auth Reset still fail. The timeout fallback fix is verified; the completed-exit fallback remains open.
Findings:
- HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
BackupRepo.kt:1091requires usable identity export before capturing ordinary reservation state. A new watch-only reservation stayed pending and the remote WALLET bytes/version stayed unchanged until credential repair. A restore from that record loses the newer ordinary state. This is the inherited terminal-WALLET finding. - MEDIUM — Reset waits for unbounded native authentication.
VssClientCoordinator.kt:138joins admitted native work inNonCancellable. Actual Reset remained wiping with credentials retained and admission closed after 202.3 seconds; release of the native auth response let it finish. The existing thread remains open. - LOW — A finished Spark exit disables the Spark-only balance fallback.
WalletRepo.kt:505andDeriveBalanceStateUseCase.kt:65test retained record presence, so Done/Failed records clear the contribution fields indefinitely. A controlled Done record reproduced stale Spending and cache after Spark changed with LDK stopped. This adds device evidence to the existing finding; the author has confirmed it and plans a fix.
QA for 4aa622f:
I built the pristine head and tested an isolated unfunded Android 16 / API 36 ARM64 dev wallet on staging. Unpublished Debug controls select timing, stage disposable credentials and supply controlled Spark/exit state. Native libraries match the pristine APK. Controlled Spark amounts are display fixtures; no payment or emergency exit was performed. Hidden state is checked in logs/readback. Videos play at 2×.
Refresh Spark balances after node startup times out ✅ Spending and cache updated after the real node-start timeout, checked in video/readback.
The real node-start wait reached its one-minute timeout while startup stayed held. Spending and persisted cache changed from 99,865 to 50,000 sats; pending deposit changed from 600 to 100 sats. The regression also covers a node becoming Running before publication and retaining its usable channel limit.
timeout-spark-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Refresh Spark balances after a finished exit ❌ A controlled Done record left newer Spark amounts stale with LDK stopped, checked in video/readback.
I staged a Done record, derived with the node Running, then stopped the real node and changed controlled Spark amounts. Spending/live/cache stayed at 99,865 sats and the old pending amount instead of following 50,000/100; the sendable limit did update. This stages the retained record, rather than executing an emergency exit.
finished-exit-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Recover the own profile after an outage ✅ Reconnect stayed nonblocking and installed the real fetched profile after release, checked in logs/readback.
After the real offline profile fetch failed, repeated reconnect calls returned promptly during a held successful read. An ordinary address allocation succeeded while that response was held. Release installed the fetched profile with the original identity retained, checked in logs/readback.
profile-reconnect-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Discard a profile response after sign-out ✅ Sign-out completed and the late real profile response was rejected, checked in logs/readback.
Normal sign-out completed while an actual profile response was held outside the identity lock. Release was rejected as stale; identity and authentication stayed absent and profile stayed null, checked in logs/readback.
profile-stale-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Retire identity during a legacy profile migration ✅ Wipe waited for real legacy session refresh, then cleared identity and credentials, checked in logs/readback.
A real Paykit-only legacy profile entered native session refresh. Identity wipe waited while that admitted refresh was held, then completed after release with profile, identity and saved credentials absent. This tests wipeLocalState, rather than Settings Reset.
migration-refresh-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Discard old avatar migration after identity replacement ✅ Same-key replacement invalidated the old avatar continuation, checked in logs/readback.
I held a real legacy avatar download, restored the same key as a new sign-in, then released the old migration. It raised SignInChanged before its old avatar write, and the replacement identity remained current. Checked in logs/readback.
avatar-replacement-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Back up ordinary changes after terminal identity failure ❌ New ordinary reservation state stayed outside the remote WALLET until credential repair, checked by SDK/VSS readback.
With an invalid saved session and its stored identity secret temporarily removed, a newly allocated ordinary watch-only index remained pending. Real VSS readback showed identical WALLET bytes/version. Restoring the original credentials allowed a complete upload.
terminal-wallet-2x.2x.mp4
App during the controlled case; hidden state checked in logs/readback.
Finish Reset while native authentication is held ❌ Reset stayed wiping for at least 202.3 seconds while real native auth was held, checked in video/timed readback.
Real native authentication stayed held for 240.1 seconds. Settings Reset remained wiping with credentials present and admission closed for at least 202.3 seconds. After response release, the lookup cancelled and Reset completion, credential removal and reopened admission were observed 13.3 seconds after release. Timing and state checked in proxy logs/readback. Two consecutive recording segments are joined in order; elapsed times come from the logs.
stalled-native-auth-2x.2x.mp4
Reset during the held native response, then completion after release.
Check: pristine build and affected regressions ✅ Assembly and the affected full test classes passed.
assembleDevDebug and testDevDebugUnitTest passed with the affected PubkyRepoTest, WalletRepoTest, BackupRepoTest, LightningRepoTest, WalletViewModelTest, SendSheetNavigationTest, SendSheetSyncOverlayTest and DeriveBalanceStateUseCaseTest filters. These are component tests, with mocked service resources. Full-suite, lint and Compose instrumentation were not repeated.
Earlier reports retain their original scope and revisions for paths this delta leaves intact:
- Startup-cache and funded Spark report: Manual 5/6: cached receiving details during startup, two real Spark sends and full withdrawal, and retained-display admission. The normal payment-result sheet and cache-load routines are unchanged; the new timeout/exit paths were driven above. The prior funded native run used Spark 0.28.0; this head pins 0.28.2.
- Restore and invoice-guard report: original-owner full VSS restore and Spark refusal of unlisted old invoices.
- Spark replay and retirement report: Manual 3 and the interrupted-debounce portion of Manual 4: status-write recovery, notification replay and an acknowledged SETTINGS PUT held across wallet replacement.
- Restore-clear and expiry report: Manual 4: explicit restore-gate clear and real ten-minute expiry for pending Spark backups.
- Required recovery and allowance report: Manual 1/2, J2 and J3: WALLET/USDT failure gates, paused restored payments, allowance admission/off-on and scheduled Settings status. That report used Paykit rc6; this head uses rc8. The app admission/token paths are unchanged, and the native execution keeps its original revision and pin.
No planned current-head case was left unrun. The earlier reports are not fresh executions on the new native pins. This run does not establish a new funded settlement, actual emergency exit, nonempty USDT import or complete seven-action J1 execution. The deferred allowance journey wording remains tracked in bitkit-android#1766 docs: clarify delayed-contact coverage in the allowance toggle journey. The non-Spark restore-end scheduling race remains the separately identified base-branch issue.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh at 0ad55417 matches deferred identity recovery, complete-WALLET refusal and VSS draining in source. The Android timeout/exit balance composition has no matching change there; no new iOS device run.
Coverage: contiguous 89f3bc6bff5c5d68d14559a860b408b006c315ce..4aa622fc4583dfda4fe06a9a95d8a6e6a953bd1a, the four first-parent commits, reached restore/profile/balance paths and inherited open findings. Imported upstream Spark exit code is part of the base; this review checks its composition with the PR. Profile-tag installation and channel-limit transitions also rely on the affected component tests.
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 at 4aa622fc after the comparison base moved from 6bb0ff55 to 82d49cce. The merge base is 82d49cce15beed55917a9519f24bae39731720e5. No earlier coverage was inherited.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
Required wallet and USDT restore finish before optional Pubky activation. Scheduled setup keeps an already active session, coalesces the same revision, and drops obsolete results. Reconnect of that active session does not fetch a deliberately absent profile while local setup is pending; importing a session still loads profile data once. Profile and avatar writes check the captured sign-in before changing storage.
VSS app and LDK work, backup retries, and routing-cache resets stay with the captured wallet generation. Credential changes suspend admission, cancel and drain admitted calls, shut the clients down, and reopen only after shutdown. A failed shutdown leaves admission closed. Scheduled backups stay Running through debounce, and a reservation that never entered its wallet cannot leave Running behind. Spark backup state is marked only after the required-status write and is reconsidered when restore clears or expires. WALLET and METADATA capture fail closed when identity data is missing or unreadable, so a complete remote snapshot is not replaced. The newer local WALLET state then stays pending on the existing failed-backup path until a later successful upload.
Spark updates while Lightning is stopped, or when startup times out, keep a known lightning and on-chain split, and recompute the spend limit from current channels if the node is Running as the read finishes. Cache hydration does not replace an in-memory balance after a live aggregate has been published. Pending, success, and error send routes do not show the node-wait overlay.
Compared with bitkit-ios#1121 at 0ad55417 for this restore and reconnect contract only. Both defer optional profile work and skip a pending-setup refetch for an active session. Android coalesces the scheduled init without clearing that session; iOS clears authenticated state and initializes again. This was not a full review of the iOS pull request.
Validation: source and the added repository tests were inspected, not executed here. On this head, hosted CI, lint, and E2E completed successfully. Migration jobs were skipped by their release guard. Device testing: not performed in this review. Journey J1 still needs the complete-app identity fault controls its description says are not provisioned.
Earlier findings reconciled at this revision: 4 still present.
Findings
- [MEDIUM] Bound native authentication before draining VSS during reset — now at
app/src/main/java/to/bitkit/data/backup/VssClientCoordinator.kt:138. Still present from the earlier review. - [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — now at
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1091. Still present from the earlier review. - [LOW] Finished Spark exit keeps the Spark-only balance fallback off — inline at
app/src/main/java/to/bitkit/repositories/WalletRepo.kt:505. Still present from an earlier unpublished review.
Existing concerns
- [LOW] Allowance journey describes a delayed-contact check its actions do not perform — still unresolved; already tracked.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — terminal WALLET availability and held-auth Reset still fail. The finished-exit balance fix is verified.
Findings:
- HIGH — Terminal identity failure blocks newer ordinary WALLET backups.
BackupRepo.kt:1091requires usable identity export before capturing ordinary reservation state. With terminal saved-identity failure, a new watch-only reservation stayed pending and the remote complete WALLET bytes/version stayed unchanged until credential repair. Restoring that older record omits the newer ordinary state. This reproduces the existing finding. - MEDIUM — Reset waits for unbounded native authentication.
VssClientCoordinator.kt:138joins admitted native calls inNonCancellable. Settings Reset was still wiping with credentials retained and admission closed at 202.6 seconds. Releasing the genuine authentication response let Reset finish 13.3 seconds later. The existing thread remains open; the VSS pin is still0.5.26-rc2.
I verified the finished-exit correction in both predicates and on the installed app. Done/Failed records permit current balances; active exits retain their guards. No new delta finding.
QA for d28a157:
I built the pristine head and drove an isolated Android 16 / API 36 ARM64 development wallet with staging Pubky/VSS and a local Spark regtest network. Device cases use the unpublished controls below; every native library matches the pristine APK. Controls stage display/exit state, hold startup/authentication, or read real native/cache/VSS state. They do not substitute SDK results. Videos play at 2×; hidden-state claims are checked in logs/readback.
Refresh Spark balances after a finished exit ✅ Done/Failed allow current balances and cache with LDK stopped or startup timed out, checked in video/readback.
Controlled Spark amounts changed from 99,865/600 to 50,000/100 sats. Live totals and persisted cache followed with the native node stopped. A Done record also allowed the update after the real one-minute startup timeout. The active-exit control continued withholding the unknown aggregate split. These are state fixtures, not an actual emergency exit.
| Stopped node | Startup timeout |
|---|---|
finished-exit-stopped-2x.2x.mp4 |
finished-exit-timeout-2x.2x.mp4 |
Keep Spark balances and payment results current with LDK stopped ✅ Real sends and a full withdrawal updated Home/cache; payment-result actions stayed usable, checked in video/native and recipient readback.
Real sends of 1,018 and 65,273 sats completed with recipient receipts. The live/native/cache balances changed 100,000 → 98,976 → 33,440 sats. The larger send left less than its invoice amount; Close and Details remained usable without a Connecting overlay. A further 1,000-sat send exercised the current library's leaf-swap signing and left 32,434 sats. The acknowledged full withdrawal then reached zero; the 20,836-sat recipient output confirmed after the quoted 11,598-sat fee. Cache hydration stayed zero. LDK's contribution was zero throughout.
The original local operator did not understand adaptor-signature v2. Those attempts were fixture failures. I backported v2 propagation and the matching upstream FROST implementation into the disposable operator, then completed the native swap and withdrawal with the unchanged Breez Spark 0.28.3 library. The fixture patch is below.
| First send | Larger send |
|---|---|
funded-spark-send-exact2-2x.2x.mp4 |
funded-spark-send-large-confirm-2x.2x.mp4 |
| Native leaf swap and send | Full withdrawal and zero balance |
|---|---|
funded-spark-send-v2-2x.2x.mp4 |
funded-spark-withdraw-v2-2x.2x.mp4 |
Reject spending a retained Spark display balance ✅ A controlled 5,000-sat display remained unspendable with native balance zero, checked in video/readback.
The retained display appeared while sendable and maximum-send amounts stayed zero. Payment entry offered no Swipe To Pay, and normal payment admission failed without changing native payment history. Only the retained display state was supplied by the control.
display-only-spark-2x.2x.mp4
Hold old invoices until Spark can prove a retry is safe ✅ Operator-proven unsent invoices were allowed; unreachable operators and the direct-Spark hint stayed refused, checked in native/admission readback.
I staged the restore timestamp around a real ordinary invoice. A genuine null operator result allowed it; stopping the operators produced RestoredInvoiceUnverified. A locally signed valid regtest invoice carrying the reserved Spark route hint was refused despite null operator status. Native payment lists stayed unchanged. This exercises admission with a controlled restore marker; it does not submit a payment retry or perform a full restore.
| Proven unsent | Operators unreachable | Spark-address hint |
|---|---|---|
restored-invoice-online-2x.2x.mp4 |
restored-invoice-unreachable-2x.2x.mp4 |
restored-invoice-hint-valid-2x.2x.mp4 |
Upload the current empty Spark exit state ✅ The native post-withdrawal export was acknowledged and readable in VSS, checked in service readback.
With no spendable leaves after withdrawal, the current native export was 139 bytes. Its decoded VSS SPARK_EXIT envelope was 250 bytes, version 1, network regtest. This verifies current serialization/upload integration for an empty state; it does not establish funded exit recovery.
spark-exit-export-2x.2x.mp4
Back up ordinary changes after terminal identity failure ❌ The new reservation stayed outside the remote WALLET until credential repair, checked by real SDK/VSS readback.
I replaced the saved session with an invalid value and temporarily removed its stored identity secret. A new watch-only account index was reserved, but WALLET backup failed, required work stayed pending, and the previous complete remote bytes/version remained unchanged. Restoring the original credentials allowed a complete upload. This reproduces the availability defect while preserving complete-backup integrity.
terminal-wallet-2x.2x.mp4
Finish Reset while native authentication is held ❌ Reset remained wiping through the 202.6-second readback and completed after response release, checked in video/timed readback.
A forwarding proxy held a genuine native authentication response. After actual Settings Reset confirmation, credentials remained present and admission stayed closed through 202.6 seconds. Release occurred 221.5 seconds after confirmation; credential removal, reopened admission and completion were observed 13.3 seconds later. Two consecutive recording segments are joined; timings come from proxy/event logs.
stalled-native-auth-2x.2x.mp4
Check: pristine build and affected regressions ✅ Assembly and the affected JVM test classes passed.
assembleDevDebug and the affected testDevDebugUnitTest class filters passed on the pristine head. These are component tests with mocked service resources. Full-suite, lint and Compose instrumentation were not repeated.
Check: retained earlier QA ✅ Unchanged boundaries retain their linked passing evidence at the reports' original revisions.
I did not repeat these unchanged cases:
- Profile and identity-retirement report at 4aa622f: Home reconnect, stale response after sign-out, legacy refresh/wipe and same-key avatar replacement.
- Spark scheduling report at cd0befc: required-write replay, storage error, interrupted debounce and a real acknowledged SETTINGS PUT across identity replacement.
- Restore-gate report at acdd99c: explicit restore clear and real ten-minute expiry.
- Allowance and required-recovery report at 710292a: Manual 1/2, allowance J2, scheduled-backup J3 and required WALLET/USDT recovery gates.
- Original-owner restore report at 2dc86ae: complete VSS restore and original-owner admission.
- Optional-outage boundary report at 78a0ff7: held optional work, leaving restore, restart recovery and stale enrichment. Its stated payment-replay limits remain.
These retain their original dependency versions and qualifications. Their reached implementations were checked across the delta. In particular, Spark exit export/import implementation is unchanged across the historical scheduling passes; the fresh current-pin integration above covers the empty post-withdrawal upload. I did not execute a complete seven-action J1 sequence or an actual unilateral exit here.
Scope: contiguous 4aa622f..d28a157 plus open findings. Imported upstream RBF, mnemonic-tag and remaining Spark exit/redo changes received source/affected-regression review without new device journeys for those features.
Twin: bitkit-ios#1121 fix: decouple wallet restore from profile refresh matches the restore/profile contract. This finished-exit predicate correction is Android-specific.
Models: review/testing gpt-6.1-sol (high); independent source review gpt-6.1-sol (high).
Unpublished test controls and deciding readbacks
Not pushed. The Debug controls stage the states/holds described above and call the real native, admission and VSS paths. Successive control builds add commands; the exercised decisions and native binaries remain those of the reviewed head.
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/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
index 000000000..11313af5e
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,269 @@
+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 sparkExit(): SparkExitRepo
+ 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 nodeService(): LightningService
+ 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) {
+ "qa1686DisplayOnly" -> { // test hook
+ check(d.sparkService().getInfo().balanceSats == 0uL) // test hook
+ val field = d.spark().javaClass.getDeclaredField("_state").apply { isAccessible = true } // test hook
+ @Suppress("UNCHECKED_CAST") // test hook
+ val state = field.get(d.spark()) as kotlinx.coroutines.flow.MutableStateFlow<SparkState> // test hook
+ state.value = state.value.copy(isReady = true, balanceSats = 0uL, keptBalanceSats = 5000uL, pendingDepositSats = 0uL) // test hook
+ kotlinx.coroutines.delay(700) // test hook
+ "staged display-only Spark state; live=${d.wallet().balanceState.value}; native=${d.sparkService().getInfo().balanceSats}" // test hook
+ } // test hook
+ "qa1686AssertDisplayOnly" -> { // test hook
+ val before = d.sparkService().listPayments() // test hook
+ val sent = d.lightning().payInvoice(requireNotNull(arg)) // test hook
+ val after = d.sparkService().listPayments() // test hook
+ check(sent.isFailure && before == after) // test hook
+ "send refused=${sent.exceptionOrNull()?.javaClass?.simpleName}; native payment list unchanged=true; sendable=${d.spark().state.value.sendableSats}" // test hook
+ } // test hook
+ "qa1686SparkInfo" -> {
+ val info = d.sparkService().getInfo() // test hook
+ "nativeBalance=${info.balanceSats}; lastSyncError=${info.lastSyncError}; payments=${d.sparkService().listPayments().map { it.id + ":" + it.status }}; live=${d.wallet().balanceState.value}; cache=${d.cache().data.first().balance}" // test hook
+ } // test hook
+ "qa1686SparkInvoice" -> d.sparkService().receiveBolt11(requireNotNull(arg).toULong(), "QA funded Spark", 3600u) // test hook
+ "qa1686SparkWithdraw" -> { // test hook
+ val address = requireNotNull(arg) // test hook
+ val before = d.sparkService().getInfo().balanceSats // test hook
+ val fee = d.spark().estimateWithdrawAllFeeSats(address).getOrThrow() // test hook
+ d.spark().withdrawAll(address, fee).getOrThrow() // test hook
+ "native withdrawal acknowledged; before=$before; fee=$fee; after=${d.sparkService().getInfo().balanceSats}" // test hook
+ } // test hook
+ "qa1686CacheMatches" -> { // test hook
+ val cache = d.cache().data.first() // test hook
+ val state = d.wallet().walletState.value // test hook
+ "addressMatches=${state.onchainAddress == cache.onchainAddress && cache.onchainAddress.isNotEmpty()}; invoiceMatches=${state.bolt11 == cache.bolt11 && cache.bolt11.isNotEmpty()}; bip21Matches=${state.bip21 == cache.bip21}; receiveUpdating=${state.isReceiveInvoiceUpdating}" // test hook
+ } // test hook
+ "qa1686HoldSpark" -> {
+ file("hold-spark-refresh").writeText("hold")
+ scope.launch {
+ val f=d.spark().javaClass.getDeclaredField("refreshMutex").apply { isAccessible=true }
+ val m=f.get(d.spark()) as kotlinx.coroutines.sync.Mutex
+ m.lock()
+ try {
+ file("spark-refresh-held").writeText("held")
+ while(file("hold-spark-refresh").exists()) kotlinx.coroutines.delay(100)
+ } finally { file("spark-refresh-held").delete();m.unlock() }
+ }
+ withTimeout(10000) { while(!file("spark-refresh-held").exists()) kotlinx.coroutines.delay(50) }
+ "held Spark refresh mutex to isolate controlled state emissions"
+ }
+ "qa1686ReleaseSpark" -> { file("hold-spark-refresh").delete(); "released Spark refresh" }
+
+ "qa1686BalanceState" -> "node=${d.lightning().lightningState.value.nodeLifecycleState}; live=${d.wallet().balanceState.value}; cache=${d.cache().data.first().balance}; cachedAddress=${d.wallet().walletState.value.onchainAddress.isNotEmpty()}; cachedInvoice=${d.wallet().walletState.value.bolt11.isNotEmpty()}; receiveUpdating=${d.wallet().walletState.value.isReceiveInvoiceUpdating}; spark=${d.spark().state.value}"
+ "qa1686StopNode" -> { d.lightning().stop().getOrThrow(); "stopped actual native node" }
+ "qa1686StartNode" -> { d.lightning().start().getOrThrow(); d.wallet().syncBalances(); "started actual node and derived real balance" }
+ "qa1686SparkState" -> {
+ val amounts = requireNotNull(arg).split(",").map { it.toULong() }
+ val field = d.spark().javaClass.getDeclaredField("_state").apply { isAccessible = true }
+ @Suppress("UNCHECKED_CAST")
+ val state = field.get(d.spark()) as kotlinx.coroutines.flow.MutableStateFlow<SparkState>
+ state.value = state.value.copy(isReady = true, balanceSats = amounts[0], keptBalanceSats = null, pendingDepositSats = amounts[1])
+ kotlinx.coroutines.delay(700)
+ "emitted controlled Spark amounts; live=${d.wallet().balanceState.value}; cache=${d.cache().data.first().balance}"
+ }
+ "qa1686Hydrate" -> { d.wallet().loadFromCache(); kotlinx.coroutines.delay(700); "live=${d.wallet().balanceState.value}; cache=${d.cache().data.first().balance}" }
+ "qa1686PrepareLocal" -> {
+ d.backupRepo().stopObservingBackups()
+ d.settings().update { it.copy(isDevModeEnabled = true) }
+ d.spark().start(0).getOrThrow()
+ "prepared disposable regtest wallet and real Spark service"
+ }
+ "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()}"
+ "qa1686SparkExitBackup" -> {
+ val export = requireNotNull(d.spark().exitBackup().getOrThrow())
+ d.backupRepo().triggerBackup(BackupCategory.SPARK_EXIT).getOrThrow()
+ val stored = requireNotNull(d.vss().getObject("SPARK_EXIT").getOrThrow())
+ val backup = json.decodeFromString<SparkExitBackupV1>(stored.value.decodeToString())
+ check(backup.network == export.network && backup.exitState.isNotBlank())
+ "current native exit export and VSS acknowledged; exportBytes=${export.exitState.length}; storedBytes=${stored.value.size}; version=${stored.version}; network=${backup.network}"
+ }
+ "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}"
+ }
+ "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"
+ }
+ "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
+ "qa1686ClearOptionalSetup" -> {
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().delete(Keychain.Key.PAYKIT_SESSION.name)
+ d.sdk().clearState()
+ d.pubkyRepo().initialize().getOrThrow()
+ "cleared disposable failed optional identity setup"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Logs" -> {
+ breez_sdk_spark.initLogging(File(context.filesDir, "qa1686-sdklogs").path, null, "info,spark=debug,spark_wallet=debug")
+ "enabled native Spark diagnostic log"
+ }
+ "qa1686Quote" -> {
+ val p = d.sparkService().prepareSend(requireNotNull(arg), null)
+ val m = p.paymentMethod as breez_sdk_spark.SendPaymentMethod.Bolt11Invoice
+ "actual native quote amount=${p.amount}; lightningFee=${m.lightningFeeSats}; sparkFee=${m.sparkTransferFeeSats}"
+ }
+ "qa1686StageOldInvoice" -> {
+ val invoice = requireNotNull(arg)
+ val prepared = d.sparkService().prepareSend(invoice, null)
+ val detail = (prepared.paymentMethod as breez_sdk_spark.SendPaymentMethod.Bolt11Invoice).invoiceDetails
+ file("invoice").writeText(invoice)
+ file("invoice-created").writeText(detail.timestamp.toString())
+ file("invoice-expires").writeText((detail.timestamp + detail.expiry).toString())
+ d.cache().update { it.copy(sparkRestoredAt = detail.timestamp.toLong() + 1) }
+ "staged pre-restore invoice; carriesSparkAddress=${d.sparkService().carriesSparkAddress(invoice)}; prefersSpark=${d.sparkService().prefersSpark}"
+ }
+ "qa1686OperatorGuard" -> {
+ val invoice = file("invoice").readText()
+ val before = d.sparkService().listPayments()
+ val status = to.bitkit.ext.runSuspendCatching { d.sparkService().lightningSendStatus(invoice) }
+ val lookup = to.bitkit.ext.runSuspendCatching { d.spark().earlierLightningSend(invoice, null, file("invoice-created").readText().toLong(), file("invoice-expires").readText().toLong()) }
+ val after = d.sparkService().listPayments()
+ check(before == after)
+ "operatorStatus=${status.getOrNull()}; operatorError=${status.exceptionOrNull()?.javaClass?.simpleName}; lookup=${lookup.getOrNull()}; allowed=${lookup.isSuccess}; error=${lookup.exceptionOrNull()?.javaClass?.simpleName}; paymentListUnchanged=true"
+ }
+ "qa1686ClearRestoreInvoice" -> {
+ d.cache().update { it.copy(sparkRestoredAt = null) }
+ "cleared controlled restore-invoice marker"
+ }
+ "qa1686StartNodeAsync" -> {
+ scope.launch { d.lightning().start().getOrThrow() }
+ "scheduled real native node start"
+ }
+ "qa1686ExitRecord" -> {
+ val f=d.sparkExit().javaClass.getDeclaredField("_state").apply { isAccessible=true }
+ @Suppress("UNCHECKED_CAST")
+ val state=f.get(d.sparkExit()) as kotlinx.coroutines.flow.MutableStateFlow<SparkExitState>
+ val record=if(arg=="clear") null else SparkExitRecord(
+ network="regtest", createdAt=1L, phase=when(arg) { "failed" -> SparkExitRecord.Phase.Failed("fixture"); "active" -> SparkExitRecord.Phase.AwaitingFunding(1000uL); else -> SparkExitRecord.Phase.Done },
+ feeRateSatPerVbyte=1uL, destination="bcrt1qfixture", leafIds=emptyList(),
+ funding=emptyList(), expectedSats=0uL,
+ )
+ state.value=state.value.copy(record=record)
+ "controlled completed-exit record=${state.value.record?.phase}; active=${state.value.isExiting}"
+ }
+ 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
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/LightningRepo.kt b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
index 578eb0914..c3a11cd27 100644
--- a/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/LightningRepo.kt
@@ -420,6 +420,14 @@ class LightningRepo @Inject constructor(
runCatching {
_lightningState.update { it.copy(nodeLifecycleState = NodeLifecycleState.Starting) }
+ if (to.bitkit.BuildConfig.DEBUG) { // test hook
+ val gate=java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-hold-node-start") // test hook
+ if(gate.exists()) { // test hook
+ java.io.File("/data/user/0/to.bitkit.dev/files/qa1686-node-start-held").writeText("held") // test hook
+ while(gate.exists()) kotlinx.coroutines.delay(100) // test hook
+ } // test hook
+ } // test hook
+
// Setup if needed
if (lightningService.node == null) {
diff --git a/app/src/main/java/to/bitkit/services/SparkService.kt b/app/src/main/java/to/bitkit/services/SparkService.kt
index d2b1448ca..1d56685d1 100644
--- a/app/src/main/java/to/bitkit/services/SparkService.kt
+++ b/app/src/main/java/to/bitkit/services/SparkService.kt
@@ -178,11 +178,16 @@ class SparkService @Inject constructor(
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name) ?: throw ServiceError.MnemonicNotFound()
val passphrase = keychain.loadString(Keychain.Key.BIP39_PASSPHRASE.name)
val config = configure(defaultConfig(network), Env.breezApiKey, isExiting)
+ val localConfig = File("/data/user/0/to.bitkit.dev/files/qa1686-spark-config.json") // test hook
+ if (to.bitkit.BuildConfig.DEBUG && localConfig.exists()) { // test hook
+ config.sparkConfig = breez_sdk_spark.parseSparkConfig(localConfig.readText()) // test hook
+ config.watchtowerFeeLadderEnabled = false // test hook
+ } // test hook
val storageDir = File(Env.sparkStoragePath(walletIndex)).apply { mkdirs() }.path
- val chainServiceUrl = Env.sparkChainServiceUrl(Env.network)
+ val chainServiceUrl = if (to.bitkit.BuildConfig.DEBUG && localConfig.exists()) "http://127.0.0.1:23090/api" else Env.sparkChainServiceUrl(Env.network) // test hook
val connected = SdkBuilder(config, Seed.Mnemonic(mnemonic, passphrase)).use { builder ->
builder.withDefaultStorage(storageDir)
- chainServiceUrl?.let { builder.withRestChainService(it, ChainApiType.ESPLORA, null) }
+ chainServiceUrl?.let { builder.withRestChainService(it, if (to.bitkit.BuildConfig.DEBUG && localConfig.exists()) ChainApiType.MEMPOOL_SPACE else ChainApiType.ESPLORA, null) } // test hook
builder.build()
}
listenerId = connected.addEventListener(The native authentication proxy only holds and forwards the genuine response:
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()The Spark-hint input is a locally signed valid regtest request and is never submitted:
import time,os,json
from bolt11 import Bolt11,encode
from bolt11.models.tags import Tags,Tag,TagChar
from bolt11.models.features import Features,Feature,FeatureState
from bolt11.types import MilliSatoshi
from coincurve import PrivateKey
from bitstring import BitArray
key=PrivateKey()
class Hint:
@property
def data(self):
return BitArray(bytes=key.public_key.format(compressed=True)+(17592187092992000001).to_bytes(8,'big')+bytes(8)+(144).to_bytes(2,'big'))
tags=Tags([Tag(TagChar.payment_hash,os.urandom(32).hex()),Tag(TagChar.payment_secret,os.urandom(32).hex()),Tag(TagChar.description,'QA controlled Spark-hint admission, never submitted'),Tag(TagChar.expire_time,3600),Tag(TagChar.features,Features.from_feature_list({Feature.payment_secret:FeatureState.required,Feature.var_onion_optin:FeatureState.required})),Tag(TagChar.route_hint,Hint())])
invoice=encode(Bolt11(currency='bcrt',date=int(time.time()),tags=tags,amount_msat=MilliSatoshi(1000000)),private_key=key.secret.hex())
print(json.dumps({'invoice':invoice,'kind':'locally signed valid regtest BOLT11 with reserved Spark route hint; no payable recipient and never submitted'}))The disposable local operator uses the current library's upstream FROST revision and propagates its v2 flag through signing and aggregation. Existing v1 callers remain v1. No app/native-library patch is involved:
diff --git a/protos/frost.proto b/protos/frost.proto
index 2c9213e..2dec57f 100644
--- a/protos/frost.proto
+++ b/protos/frost.proto
@@ -231,6 +231,7 @@ message SubUserSignatureShare {
}
message FrostSigningJob {
+ bool adaptor_v2 = 11;
// The unique identifier for the signing job.
string job_id = 1;
@@ -297,6 +298,7 @@ message SignFrostResponse {
* This request is sent to the server for the aggregate signing.
*/
message AggregateFrostRequest {
+ bool adaptor_v2 = 12;
// The message to sign.
bytes message = 1;
diff --git a/protos/spark.proto b/protos/spark.proto
index e37d7cd..2c85990 100644
--- a/protos/spark.proto
+++ b/protos/spark.proto
@@ -1952,6 +1952,7 @@ message InitiateSwapPrimaryTransferResponse {
// T = t * G
// ```
message AdaptorPublicKeyPackage {
+ bool adaptor_v2 = 4;
bytes adaptor_public_key = 1;
bytes direct_adaptor_public_key = 2;
bytes direct_from_cpfp_adaptor_public_key = 3;
diff --git a/protos/spark_internal.proto b/protos/spark_internal.proto
index 8ff81aa..fcc2e13 100644
--- a/protos/spark_internal.proto
+++ b/protos/spark_internal.proto
@@ -144,6 +144,7 @@ message FrostRound1Response {
}
message SigningJob {
+ bool adaptor_v2 = 10;
// The unique identifier for the signing job.
string job_id = 1 [(validate.rules).string.uuid = true];
diff --git a/signer/Cargo.toml b/signer/Cargo.toml
index affc4c3..ed66564 100644
--- a/signer/Cargo.toml
+++ b/signer/Cargo.toml
@@ -19,8 +19,8 @@ prost-types = "0.13.3"
tonic = "0.12.3"
tokio = { version = "1.41.0", features = ["macros", "rt-multi-thread", "signal"] }
tokio-stream = "0.1.16"
-frost-secp256k1-tr = { git = "https://github.com/lightsparkdev/frost", rev = "997427e5c09e62459230ad711ef3a33fb64b9424" }
-frost-core = { git = "https://github.com/lightsparkdev/frost", rev = "997427e5c09e62459230ad711ef3a33fb64b9424" }
+frost-secp256k1-tr = { git = "https://github.com/lightsparkdev/frost", rev = "0248c4be09f878cac5c284244da36ef3a185b001" }
+frost-core = { git = "https://github.com/lightsparkdev/frost", rev = "0248c4be09f878cac5c284244da36ef3a185b001" }
rand = "0.8.5"
serde = "1.0.214"
serde_bytes = "0.11.15"
diff --git a/signer/spark-frost/src/bridge.rs b/signer/spark-frost/src/bridge.rs
index ceac0d5..79965ba 100644
--- a/signer/spark-frost/src/bridge.rs
+++ b/signer/spark-frost/src/bridge.rs
@@ -80,6 +80,7 @@ pub fn sign_frost(
verifying_key: key_package.public_key.clone(),
commitments: statechain_commitments,
adaptor_public_key: adaptor_public_key.unwrap_or_default(),
+ adaptor_v2: false,
signing_scheme: proto::frost::SigningScheme::SingleUser.into(),
subuser_commitments: vec![],
};
@@ -123,6 +124,7 @@ pub fn aggregate_frost(
verifying_key,
user_signature_share: self_signature,
adaptor_public_key: adaptor_public_key.unwrap_or_default(),
+ adaptor_v2: false,
signing_scheme: proto::frost::SigningScheme::SingleUser.into(),
subuser_shares: vec![],
};
diff --git a/signer/spark-frost/src/signing.rs b/signer/spark-frost/src/signing.rs
index c3f0584..b27602d 100644
--- a/signer/spark-frost/src/signing.rs
+++ b/signer/spark-frost/src/signing.rs
@@ -70,9 +70,11 @@ pub fn frost_build_signin_package(
message: &[u8],
signing_participants_groups: Option<Vec<BTreeSet<Identifier>>>,
adaptor_public_key: &[u8],
+ adaptor_v2: bool,
) -> SigningPackage {
let adaptor_public_key = VerifyingKey::deserialize(adaptor_public_key).ok();
- SigningPackage::new_with_adaptor(
+ let make_package = if adaptor_v2 { SigningPackage::new_with_adaptor_v2 } else { SigningPackage::new_with_adaptor };
+ make_package(
signing_commitments,
signing_participants_groups,
message,
@@ -348,6 +350,7 @@ fn sign_frost_job(job: &FrostSigningJob, req: &SignFrostRequest) -> Result<Signa
&job.message,
Some(signing_participants_groups),
&job.adaptor_public_key,
+ job.adaptor_v2,
);
tracing::info!("Building signing package completed");
@@ -511,6 +514,7 @@ pub fn aggregate_frost(req: &AggregateFrostRequest) -> Result<AggregateFrostResp
&req.message,
Some(signing_participants_groups),
&req.adaptor_public_key,
+ req.adaptor_v2,
);
let signature_shares = frost_signature_shares_from_proto(
@@ -723,6 +727,7 @@ pub fn validate_signature_share(req: &ValidateSignatureShareRequest) -> Result<(
&req.message,
Some(signing_participants_groups),
&adaptor_key,
+ false,
);
let dummy_signing_share = SigningShare::deserialize(&[0; 32])
diff --git a/spark/so/handler/frost_aggregation.go b/spark/so/handler/frost_aggregation.go
index 15deef2..7cd6e7d 100644
--- a/spark/so/handler/frost_aggregation.go
+++ b/spark/so/handler/frost_aggregation.go
@@ -317,6 +317,7 @@ func (b *frostAggregationBatch) addSigningResultJob(
// limits. A signer that does not serve the batch RPC degrades to one
// aggregate_frost call per job in deterministic key order.
func (b *frostAggregationBatch) aggregate(ctx context.Context, frostClient pbfrost.FrostServiceClient) (map[string][]byte, error) {
+ for _, req := range b.requests { req.AdaptorV2 = helper.AdaptorV2(ctx) }
signatures := make(map[string][]byte, len(b.requests))
if len(b.requests) == 0 {
return signatures, nil
diff --git a/spark/so/handler/signing_handler/frost_signing_handler.go b/spark/so/handler/signing_handler/frost_signing_handler.go
index fe9a98d..716c517 100644
--- a/spark/so/handler/signing_handler/frost_signing_handler.go
+++ b/spark/so/handler/signing_handler/frost_signing_handler.go
@@ -224,6 +224,7 @@ func (h *FrostSigningHandler) FrostRound2WithKeyPackages(ctx context.Context, re
Commitments: job.GetCommitments(),
UserCommitments: job.GetUserCommitments(),
AdaptorPublicKey: job.GetAdaptorPublicKey(),
+ AdaptorV2: job.GetAdaptorV2(),
SigningScheme: job.GetSigningScheme(),
SubuserCommitments: job.GetSubuserCommitments(),
}
diff --git a/spark/so/handler/transfer_handler.go b/spark/so/handler/transfer_handler.go
index f2109a4..9d65bd7 100644
--- a/spark/so/handler/transfer_handler.go
+++ b/spark/so/handler/transfer_handler.go
@@ -897,6 +897,7 @@ func (h *TransferHandler) StartLeafSwap(ctx context.Context, req *pb.StartTransf
// applied, awaiting a counter swap transfer.
// Swap V3 flow requires adapted signatures, so the User must provide the adaptor public keys.
func (h *TransferHandler) InitiateSwapPrimaryTransfer(ctx context.Context, req *pb.InitiateSwapPrimaryTransferRequest) (*pb.StartTransferResponse, error) {
+ ctx = helper.WithAdaptorV2(ctx, req.GetAdaptorPublicKeys().GetAdaptorV2())
adaptorPublicKey, err := keys.ParsePublicKey(req.GetAdaptorPublicKeys().GetAdaptorPublicKey())
if err != nil {
return nil, fmt.Errorf("unable to parse adaptor public key: %w", err)
@@ -993,6 +994,7 @@ func (h *TransferHandler) syncTransferInit(
if swapV3Package != nil {
adaptorPublicKeyPackage = &pb.AdaptorPublicKeyPackage{
AdaptorPublicKey: cpfpAdaptorPubKey.Serialize(),
+ AdaptorV2: helper.AdaptorV2(ctx),
DirectAdaptorPublicKey: directAdaptorPubKey.Serialize(),
DirectFromCpfpAdaptorPublicKey: directFromCpfpAdaptorPubKey.Serialize(),
}
diff --git a/spark/so/helper/adaptor_v2.go b/spark/so/helper/adaptor_v2.go
new file mode 100644
index 0000000..771f170
--- /dev/null
+++ b/spark/so/helper/adaptor_v2.go
@@ -0,0 +1,5 @@
+package helper
+import "context"
+type adaptorV2Key struct{}
+func WithAdaptorV2(ctx context.Context, enabled bool) context.Context { return context.WithValue(ctx, adaptorV2Key{}, enabled) }
+func AdaptorV2(ctx context.Context) bool { enabled, _ := ctx.Value(adaptorV2Key{}).(bool); return enabled }
diff --git a/spark/so/helper/signing_coordinator.go b/spark/so/helper/signing_coordinator.go
index 271335c..b255c45 100644
--- a/spark/so/helper/signing_coordinator.go
+++ b/spark/so/helper/signing_coordinator.go
@@ -200,6 +200,7 @@ func frostRound2(
Commitments: commitments,
UserCommitments: userCommitmentProto,
AdaptorPublicKey: adaptorPublicKeyBytes,
+ AdaptorV2: AdaptorV2(ctx),
}
}
Deciding readback summaries (hidden assertions above):
Done/Failed: live/cache Spark amount 99865 -> 50000; pending 600 -> 100; node Stopped.
Done + held native startup: same update after the actual one-minute timeout; active exit still withholds the unknown split.
Native Spark sends: 100000 -> 98976 -> 33440 -> 32434; each send COMPLETED and recipient SUCCEEDED.
Native withdrawal acknowledged: before=32434; fee=11598; after=0.
Recipient: confirmed_sats=20836; matching withdrawal txid.
Retained display: native=0; shown=5000; sendable=0; native payment list unchanged=true.
Old invoice: null operator status -> allowed; operator connection failure -> RestoredInvoiceUnverified.
Spark hint: carriesSparkAddress=true; prefersSpark=true; null operator status -> RestoredInvoiceUnverified.
SPARK_EXIT: native exportBytes=139; storedBytes=250; VSS version=1; network=regtest.
Terminal: ordinary watch-only index=1 reserved; backup failed; remote bytes/version unchanged; required pending=true.
Repair: recovered original identity and complete WALLET upload.
Reset held at 202.6s: credentialsPresent=true; wiping=true; admissionOpen=false.
After response release: credentialsPresent=false; wiping=false; admissionOpen=true; Reset completed in 13.3s.
Reviewed by gpt-6.1-sol-high
November 12 · restore Spark balance updates after a finished exit
Published
d28a1578on52bc2736(validated treeb5b0fd34). Retained Done/Failed exit records now use the existingisExitingpredicate in both derivation and the stopped/timeout fallback. Spark and active-transfer exit guards remain. The extended existing regression first reproduced the stale 119,865-sat total instead of 70,000, then passed with the correction.Final local formatting, compile, full tests, lint and APK checks passed: 5,981 unit passes plus two explicit opt-in regtest skips; 133 focused passes. Complete Detekt/Android Lint reports have zero findings. All 35 live-base commits verify locally and on GitHub.
Original hosted CI, Lint and E2E passed on matching synthetic
bd39ccf6: 5,981 unit passes plus two skips, 257 Compose passes and 39 first-attempt E2E passes. Settings08 and six migration jobs were skipped; no job/workflow rerun. Executed external suite:ce9c0e2b.Complete diagnostics retain a real target-package Zygote abort during explicit suite reinstall (nine shard cases subsequently passed), 29 stale-element helper notices, fee disagreement followed by force-close/commitment broadcast, restore-wait guards, metadata refusals/later acknowledgements and widget cancellation. The exact abort initiator and actual-base runtime are not established. Hosted unit XML/Gradle Problems HTML were not uploaded; local reports remain separate evidence. These controls do not establish a funded finished-exit device journey, complete WALLET recovery, authenticated endpoint cleanup or native Reset Wallet. Earlier failures and authored QA remain below.
November 12 · refresh Spark balances after node startup times out
Published
b2c3ea6e0against12d78484. The existing Spark fallback now handlesNodeRunTimeoutErrorand preserves the current usable channel limit when the node becomes Running as the read finishes. The one-minute wait and cancellation handling are unchanged.Local final formatting, compilation, full tests, lint and APK checks passed on tree
b526c8b2: 5,579 unit tests, 111 focusedWalletRepoTestcases and zero findings in six static reports. The new control first reproduced the stale total (119865 instead of 70000), then the recovered-channel limit (39900 instead of 55000). All 32 commits in the live-base range verify locally and on GitHub.The original CI, Lint and E2E completed successfully on synthetic
8fbb0516, whose tree equals the local result. Hosted results: 5,579 units, 256 Compose tests, six clean static reports and 39 unique E2E cases eventually passed. Send's first@send_2attempt stayed on Home after an acknowledged Send tap; the recipient/permission screen did not appear within 30 seconds. Its configured second attempt passed and skipped two already-passed cases. The cause of the first failure remains unproven. Settings08 remains disabled; all six migration jobs were skipped.Runtime qualifications remain: Transfer passed while a 139/182-sat peer fee disagreement caused a force-close and commitment broadcast; three restore waits later completed in the same process; four METADATA acknowledgements and two widget-fetch cancellations were inspected. These results establish neither confirmed returned funds nor new private profile, complete-WALLET, native-reset or withdrawal-journal retirement acceptance. Earlier failures and exact-head results below remain historical. Later GitHub mergeability metadata returned dirty/null despite unchanged head/base; the completed checks retain their original recorded checkout identity.
November 12 · keep cached wallet data available during startup
At
89f3bc6b, cache hydration can publish cached balances and receiving details while a native balance read waits for node startup. Balance derivations remain serialized, final publication remains atomic, and the original wallet admission and owner checks remain in place. The local regression failed onff9275d6with the cached 50,000-sat balance still at zero; it passes with this fix and checks that a subsequent refresh remains current.All five final local commands passed against master
6bb0ff55: formatting, compilation, 5,442 unit tests, lint and APK assembly. There were no test failures, skips or retries and no findings in six native static reports. The full WalletRepo class passed 110 cases. Two independent source reviews and the exact child-composition qualification found no outstanding defect.The first original hosted CI run, Lint run and E2E run passed. Synthetic commit
66206d4ehas the exact validated tree. Hosted results: 5,442 unit tests, 256 Compose tests, six static reports with zero findings and 39 enabled E2E cases passing on their first internal attempt. One Settings case remained disabled; all six migration jobs were skipped by their release-ref guards. The executed E2E suite was0ca01e0a, whose restore helper changed since the preceding run; it is distinct from the selected reusable-workflow revision.Complete available diagnostics retain the transfer peer-fee disagreement, force-close and commitment broadcast: the passing UI does not prove a cooperative close or confirmed returned funds. A stock restore wait hit its 30-second guard before later same-process restore success. Six guarded METADATA refusals received later same-process acknowledgements. Optional restore categories and no-session remote-cleanup warnings remain qualified. Unit XML and Gradle Problems HTML were not uploaded. Compose fixture results, stock E2E results and the controlled local startup regression do not establish authenticated/private recovery, native Reset Wallet or complete SDK WALLET acceptance.
All 30 commits in the live-base range verified locally and on GitHub. The earlier installed cold-start reproduction remains attributed to its original head; manual case 6 records the current-head fixture still needed. Earlier source, formatter and runtime failures remain below with their original inputs.
November 12 · keep Spark balances and payment results current
At
ff9275d6, Spark balance changes update Home/Spending while LDK is stopped, preserving known Lightning and on-chain contributions. Cache hydration and retired wallet writes cannot overwrite newer balances. Pending, Success and Error payment screens retain their Close/Details actions when the remaining Spark balance falls below the paid invoice; the existing admission checks remain in place for payment entry.Final formatting, compilation, full tests, lint and APK assembly passed on tree
413b4374against master6bb0ff55: 5,441 unit tests, zero failures/skips/retries and zero findings in six native static reports. The ten added balance, cache, cancellation and navigation controls passed alongside all 36 previous controls. Independent source reviews cover the affected production and child composition paths. All 29 live-base commits verify locally and on GitHub.The first original CI, Lint and E2E runs passed on synthetic
d6183ae4, with the same validated tree: 5,441 units, 256 Compose cases and 39 E2E passes. All seven E2E shards passed on internal attempt one, with no failed test execution;settings_08remains intentionally disabled and six migration jobs were skipped. Complete available diagnostics and artifact provenance were inspected. The 48 helper notices are separate from testcase retries.Runtime qualifications remain: Transfer recorded the peer's 139 versus 182 sat closing-fee disagreement, force-close and commitment broadcast; its passing UI assertions do not establish cooperative closure or confirmed returned funds. An initial native on-chain sync timeout later reached same-process sync completion and a successful explicit app retry. The 30-second restore wait guard was followed by a successful full restore, with WALLET/BLOCKTANK absent. Six guarded METADATA refusals had later same-process acknowledgements. A Zygote abort during test-package uninstall occurred before app initialization; the replacement process passed, without an established introduced app-code cause. Compiler/provider/JVM warnings and transient emulator connection notices remain recorded.
Current inputs are Core
0.8.0-rc14, Paykit0.1.1-rc8, LDK0.7.0-rc.80, VSS0.5.26-rc2and Spark0.28.0; older pin lists below describe their original evidence. These checks do not include the unreleased owned SDK changes. Hosted stock fixtures do not reproduce the exact controlled stopped-node Spark journey, authenticated cleanup, terminal complete-WALLET recovery or bounded native Reset. Manual case 5 below records the remaining controlled app check. The earlier red balance regression and formatter/test-fixture failures remain preserved; passing controls do not erase them. Earlier evidence and authored QA notes follow.November 12 · retain profile ownership with the current restore guards
At
2dc86ae0, the composition with master6bb0ff55retains the original sign-in throughout deferred profile refresh, migration and avatar writes. Wipe or replacement invalidates that work before it mutates storage or publishes profile state. The upstream invoice-history holds remain inside admitted recovery, after uploads drain and before category reads. Ordinary remote profile reads remain outside the identity-write lock.Final formatting, compilation, full lint and APK assembly passed on tree
32497f24, with 5,431 unit tests, zero failures/skips/retries and zero introduced findings. All 36 affected controls passed, including the four identity controls and 32 added upstream cases. Two independent affected source reviews cover the composition. All 28 commits against the live base verify locally and on GitHub.The first original CI, Lint and E2E runs passed on synthetic
e1514446, whose tree equals the local validated tree: 5,431 unit tests, 256 Compose cases and 39 E2E passes. All seven E2E shards ran on attempt one with matching APK provenance and no testcase retry.settings_08remains intentionally disabled; six migration jobs were skipped. Complete available diagnostics retain 37 helper/stale-element notices and inherited compiler, JVM and provider warnings.The hosted
transfer_2case again recorded a peer closing-fee disagreement (139 versus 182 sat), followed by force-close and commitment broadcast. Its UI-success and connection-removal assertions passed; they do not establish cooperative closure or confirmed returned funds. The direct close path and LDK pin match base, without a base-runtime reproduction or an established introduced cause. Four guarded METADATA refusals had later same-process acknowledgements. Activity-tag cancellation remains unexplained. These stock checks do not establish authenticated profile cleanup, terminal complete-WALLET recovery, bounded native Reset or private SDK acceptance.Earlier
cd1d1290validation remains scoped to tree91ce475cand base14e6d3dd: 389 focused and 5,399 full local tests, 27 verified commits, then 5,399 hosted units, 256 Compose cases and 39 E2E passes in the original run. Its 43 helper notices, force-close, three guarded metadata refusals and Legacy-account sync timeout remain recorded. The current run did not reproduce that exact Legacy timeout; it does not establish a fix for it. The three original stale-write failures and later passing controls retain their original revisions.The deferred-refresh finding already has its verified fix reply. Existing human requests remain pending. Earlier evidence and authored QA notes continue 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
The Spark correction retains the existing Home/Spending and Send result layouts; it updates displayed balances and prevents an unrelated node-wait overlay from covering the payment result.
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.
For the current correction, Case 5 needs a separate disposable Spark regtest wallet, synthetic funds, a test recipient, and a controlled way to stop only LDK while Spark remains connected. Native balance readback and the selected operation's acknowledgement must be observable. The standard journey capability table does not provide this controlled Spark setup. Keep the published dependency pins; no private SDK, wallet reset or live funds are part of this case.
For manual case 6, use an isolated disposable development wallet with known cached balances and receiving data, a controlled native-startup hold, and independently releasable cache loading. These controls are unavailable in the standard journey fixture. Display amounts are fixture data; no payment or wallet reset is needed.
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.5. With LDK stopped and Spark ready, record the native Spark balance and the Home/Spending total. Complete a small Spark send to the test recipient, then a second send, and finally withdraw the remaining Spark balance using the fixture's authorized native operation. After each send, verify the payment result and its Close/Details actions remain visible without a Connecting to Network overlay, including when the remaining Spark balance is below the paid invoice amount. After each acknowledged balance change, verify Home/Spending reflects the current shown Spark amount while preserving any known LDK contribution and the on-chain balance. Check the zero-Spark result and reopen the cached screen. When Spark retains a displayed balance after a failed read, verify that display is not offered as spendable funds. Record the app revision, fixture configuration, native before/after amounts and acknowledgements. This is a prepared manual case, not an author-run app pass; local repository/observer controls and the earlier external reproduction remain separately attributed.
6. Cold-start the controlled wallet with native node startup held. Allow cached data to load while a balance refresh waits for the node. Verify Home shows the known cached balance and the already-validated cached address/invoice becomes available before releasing native startup. Release the node hold and verify a fresh balance replaces the cached value; a later refresh must remain current if another earlier refresh was delayed. Keep any receiving address that still needs reservation validation unavailable. Record app/build identity, holds and release order, and cached versus fresh readbacks. The repository regression covers cache publication and refresh ordering; a new author-run installed-app execution needs the controlled fixture. The external failed cold-start reproduction remains attributed to
ff9275d6.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
WalletRepoTest.kt— keeps known balance contributions and original-wallet ownership through stopped-node Spark changes, cache hydration and retirement.WalletViewModelTest.kt— continues observing swaps after a wallet refresh is retired.SendSheetNavigationTest.kt— keeps payment results visible after the remaining balance falls below the paid invoice, and restores the wait when returning to Confirm.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.