Repository navigation
fix: preserve pending backups across restart - #1689
ben-kaufman wants to merge 30 commits into
Conversation
|
Triage: 3.1.0. It fixes shipped backup handling, and 3.1.0 is due 28 Oct. |
[Medium impact] The changes affect wallet backup and recovery work in the Android app. Fix the restart gap before merging so edits made during old cleanup are not silently left out of backups. Findings
SummaryThis PR makes reset wait for retained backup work and keeps explicit backups separate from observation.
Diagram%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Stop observation] --> B[Cancel old observer jobs]
B --> C[Restart requested]
C --> D[Wait for old cleanup]
D --> E[Start new data listeners]
E --> F[Drop initial values]
G[User edits settings during wait] --> D
G --> F
F --> H[Edit never marks backup required]
Reviews (1) · Last reviewed commit: "fix: preserve pending backups across res..." · Reviewed by Greptile |
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
talosmachina
left a comment
There was a problem hiding this comment.
No findings. Moves every backup task under a reset-owned scope with a generation fence, so reset drains interrupted setup, uploads and status writes before clearing the VSS clients, and a stopped or restarted observer keeps pending categories. Reviewed a8f0810, full tier, reasoned from the code and CI (not built here).
What I checked, and 6 candidates I ruled out
Read in full: the BackupRepo.kt diff and its lifecycle paths (reset, startObservingBackups, scheduleBackup, withBackupOperation, performBackup, retryAfterTemporaryFailure), PaykitSdkOperationLock.kt, PaykitSdkService.withWalletWipe / exportBackupState, WipeWalletUseCase.kt, BackupsViewModel.kt, UsdtService.recoveryBackup
Call sites traced: reset (only WipeWalletUseCase.stopNode), triggerBackup, persistUsdtRecoveryBackup, scheduleFullBackup, start/stopObservingBackups
CI: build (includes testDevDebugUnitTest) and lint green; e2e jobs still running at review time
Ruled out
- Reset hangs the wipe:
resetjoins pending work underNonCancellablewhilewithWalletWipeholds the Paykit lock. A background WALLET export blocked there is inacquire, whoseawaitis cancellable and removes its waiter; the SDK call's ownNonCancellablesection cannot be entered while the wipe holds the lock. Covered bywipe admission drains a backup queued at the real SDK operation barrier. - Lost update while a scheduled job is mid-debounce: the old cancel-and-restart is gone, but the status observer skips because
runningis true, the job reads the latest status after the debounce, and a change during upload bumpsrequiredpastbackupRequired, so the job'sfinallyreschedules.full backup follows newer required data after observers coalescecovers the follow-up. - Reschedule loop in the job's
finally: a permanent failure setsfailedBackupRequired, soshouldBackupstops it;WalletBackupSnapshotChangedre-runs as it did on the base through therunningtrue to false transition. - Lock inversion:
categoryMutexesthenwalletBackupMutex/usdtBackupMutexon the scheduled path, the inner mutex alone onpersist*;lifecycleLockis only taken in non-suspending blocks, including inside the DataStore transform. - Restart dropped during reset:
startObservingBackupsreturns whileresetting, but the base already returned while wiping, and the only reset caller restarts observation itself on failure. - Greptile's restart gap: real, but the class predates this PR. On the base an edit made while observation is stopped is already discarded by the listeners'
drop(1)on restart; this change extends that window by the old observation's cancellation cleanup, which is short (cancellable uploads plus oneNonCancellablestatus write).
Merge confidence: 4/5, no findings, but the change is reasoned from code and CI rather than built here, and the e2e jobs had not finished.
Reviewer model: claude-opus-5-5
Reasoning effort: Unknown (not exposed to the reviewing session)
jvsena42
left a comment
There was a problem hiding this comment.
Code scan: no findings.
Checked and clean:
- A backup is marked synced only after the put returns success. Stop, restart and wipe keep consumed pending markers. Only the fully-stopped window drops edits, which was already discussed here.
- After a wipe and restore, the old wallet's pending data can't upload under the new wallet: the generation fence and the drained join stop it.
BackupItemStatusis persisted in the same format as v3.0.0, so nothing to migrate.- Twin synonymdev/bitkit-ios#1123 serialises the observer lifecycle the same way.
Performance-relevant (observation restarts on every background/foreground cycle and now waits for the old cleanup). The gate measures the gap between Stopped observing and Start observing per cycle, and counts Backup starting for per single edit (expected 1), head vs master.
Device gate (partial): e585fcc — not run yet; pending that measurement. Device results will follow in a separate review.
|
@ben-kaufman please resolve conflicts. |
There was a problem hiding this comment.
Verdict: ✅ Approve
Findings: None.
QA for e585fcc: Android 15 (API 35), arm64. I built the reviewed head and drove an isolated fault fixture using the app's real DataStores and native encrypted VSS transport. Local hooks hold operations and expose their results; a proxy supplies a controlled VSS conflict. Recovery payloads are synthetic, so these results do not establish native transaction signing or a deployed-contract outage journey.
Test 1: pending changes across restart ✅ Settings and a due retry waited for old cleanup, then uploaded without another edit.
I held WIDGETS cancellation cleanup, restarted observation, changed synchronized settings to EUR, and let a BLOCKTANK retry become due. Replacement setup and uploads waited for release. Decrypted VSS readback contained EUR, with the same payload digest as the upload.
case1-2x.2x.mp4
Restart drain and native VSS readback, 2×.
Test 2: reset drains manual and recovery persistence ✅ Cancelled reset callers still waited; cancelled recovery was not acknowledged, and retry retained the same envelope.
Both VSS clients reset only after the held write returned. The recovery acknowledgement counter stayed at zero after cancellation, then advanced once on retry with observation stopped. Both writes used the same content-addressed key and payload digest. This checks the real app callback with a synthetic recovery string.
case2-2x.2x.mp4
Reset cancellation and recovery retry, 2×.
Test 3: wipe admission ✅ Manual, full and recovery requests produced no upload during wipe; explicit recovery worked after release.
SETTINGS and USDT recovery status snapshots remained unchanged while admission was closed after reset. The recovery request completed after release without starting observation.
case3-2x.2x.mp4
Wipe admission and stopped-observer recovery, 2×.
Test 4: full-backup follow-up ✅ New revisions, a held required-status write, and a full request during stopped-job cleanup all resumed without another edit.
I drove the shared scheduler through SETTINGS. A change during the first upload produced a second upload; a consumed change survived stop/restart while its cache write was held; a full request waited behind cancelled cleanup and completed with observation stopped. Final decrypted VSS readback contained CHF.
case4-2x.2x.mp4
Revision follow-up, cache-write drain and full-request admission, 2×.
Test 5: clean restart timing and upload count ✅ The head added only milliseconds on the idle fixture, and one edit produced one SETTINGS upload at both revisions.
Measured from the app's stop/start log timestamps: head gaps were 2–11 ms (median 5 ms); exact-base gaps were 0–1 ms (median 1 ms). These are direct observation cycles against ac2a7c668, rather than physical background transitions or a comparison with later master changes.
Head e585fcc |
Base ac2a7c668 |
|---|---|
case5-head-2x.2x.mp4 |
case5-base-2x.2x.mp4 |
Idle lifecycle cycles and a single settings edit, 2×.
Check 1: build and committed regressions ✅ assembleDevDebug and the focused BackupRepoTest suite passed on the unmodified head.
The restart finding is repaired at this head; I checked its listener and retry-readiness paths against the fresh regression and device results.
Coverage: Full ac2a7c668..e585fcc, including reached reset/wipe, SDK-lock and recovery-acknowledgement paths. No planned fixture case remains unrun. Device full-backup checks exercised SETTINGS; complete WALLET capture and signed-payment recovery internals were checked in code and the committed component suite, without a new complete payment-fault journey.
Twin: bitkit-ios#1123 fix: serialize backup observation lifecycle matches the lifecycle and pending-change intent. Android invalidates observation immediately and drains on restart/reset; iOS awaits shutdown.
Local test controls — not pushed
The patch adds explicit holds and logging, plus a shell-only control surface for a disposable installation. It does not replace native VSS results or change the backup decisions under review. The proxy forwards encrypted traffic to staging and can return a protobuf conflict; the native client converts that into the app's normal retry path.
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index b88fe73a7..c1aa86be7 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -11,5 +11,8 @@
android:authorities="${applicationId}.devtools"
android:exported="true"
tools:ignore="ExportedContentProvider" />
+ <!-- test hook -->
+ <provider android:name="to.bitkit.dev.BackupFaultProvider" android:authorities="${applicationId}.backupfault" android:exported="true" tools:ignore="ExportedContentProvider" />
+ <activity android:name="to.bitkit.dev.BackupFaultActivity" android:exported="true" />
</application>
</manifest>
diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt
index 9966a479f..31a561531 100644
--- a/app/src/main/java/to/bitkit/data/CacheStore.kt
+++ b/app/src/main/java/to/bitkit/data/CacheStore.kt
@@ -38,6 +38,7 @@ class CacheStore internal constructor(
val backupStatuses: Flow<Map<BackupCategory, BackupItemStatus>> = data.map { it.backupStatuses }
suspend fun update(transform: (AppCacheData) -> AppCacheData) {
+ to.bitkit.testhook.BackupFault.hold("cache") // test hook
store.updateData(transform)
}
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
index eef9af0b1..4d59ab2d0 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
@@ -1,5 +1,6 @@
package to.bitkit.data.backup
+import to.bitkit.testhook.BackupFault // test hook
import com.synonym.vssclient.KeyVersion
import com.synonym.vssclient.VssItem
import com.synonym.vssclient.vssDelete
@@ -36,6 +37,8 @@ class VssBackupClient @Inject constructor(
private val setupMutex = Mutex()
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ BackupFault.event("SETUP_ENTER") // test hook
+ BackupFault.hold("setup", drain = true) // test hook
setupMutex.withLock {
val gate = isSetup
runCatching {
@@ -109,6 +112,7 @@ class VssBackupClient @Inject constructor(
}
fun reset() {
+ BackupFault.event("RESET_VSS") // test hook
synchronized(this) {
isSetup.cancel()
isSetup = CompletableDeferred()
@@ -122,6 +126,7 @@ class VssBackupClient @Inject constructor(
data: ByteArray,
): Result<VssItem> = withContext(ioDispatcher) {
isSetup.await()
+ BackupFault.beforePut(key, data) // test hook
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runCatching {
vssStore(
@@ -129,8 +134,11 @@ class VssBackupClient @Inject constructor(
value = data,
)
}.onSuccess {
+ BackupFault.event("PUT_OK $key sha256=${BackupFault.digest(data)}") // test hook
+ BackupFault.hold("post:${BackupFault.gateKey(key)}", drain = true) // test hook
Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG)
}.onFailure {
+ BackupFault.event("PUT_ERROR $key ${it.javaClass.simpleName}") // test hook
Logger.verbose("VSS 'putObject' error for '$key'", it, context = TAG)
}
}
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 7842dbf19..7c6472c57 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
@@ -43,6 +43,7 @@ class VssBackupClientLdk @Inject constructor(
private val setupMutex = Mutex()
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.event("LDK_SETUP_ENTER") // test hook
setupMutex.withLock {
val gate = isSetup
runCatching {
@@ -75,6 +76,7 @@ class VssBackupClientLdk @Inject constructor(
}
fun reset() {
+ to.bitkit.testhook.BackupFault.event("RESET_LDK") // test hook
synchronized(this) {
isSetup.cancel()
isSetup = CompletableDeferred()
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
index 09797720d..6bd811c54 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -111,7 +111,7 @@ internal object Env {
val vssStoreIdPrefix get() = "bitkit_v1_${network.name.lowercase()}"
val vssServerUrl
- get() = when (network) {
+ get() = if (to.bitkit.BuildConfig.DEBUG) "http://127.0.0.1:24889" else when (network) { // test hook
Network.BITCOIN -> "https://bitkit.to/vss_rs_auth"
else -> "https://bitkit.stag0.blocktank.to/vss_rs_auth"
}
diff --git a/app/src/main/java/to/bitkit/testhook/BackupFault.kt b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
new file mode 100644
index 0000000..f99eb5b
--- /dev/null
+++ b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
@@ -0,0 +1,40 @@
+// test hook: local review fixture, never shipped.
+package to.bitkit.testhook
+
+import android.util.Log
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import to.bitkit.BuildConfig
+
+object BackupFault {
+ private val gates = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
+ val events = CopyOnWriteArrayList<String>()
+ fun event(text: String) {
+ if (!BuildConfig.DEBUG) return
+ val line = "${android.os.SystemClock.elapsedRealtime()} $text"
+ events.add(line)
+ Log.i("BackupFault", line)
+ }
+ fun arm(name: String) { gates[name] = CompletableDeferred(); event("ARM $name") }
+ fun release(name: String) { gates.remove(name)?.complete(Unit); event("RELEASE $name") }
+ fun releaseAll() { gates.keys.toList().forEach { release(it) } }
+ suspend fun hold(name: String, drain: Boolean = false) {
+ if (!BuildConfig.DEBUG) return
+ val gate = gates[name] ?: return
+ event("HELD $name")
+ if (drain) withContext(NonCancellable) { gate.await() } else gate.await()
+ gates.remove(name, gate)
+ event("EXIT $name")
+ }
+ fun gateKey(key: String) = if (key.startsWith("USDT_RECOVERY_")) "USDT_RECOVERY" else key
+ suspend fun beforePut(key: String, data: ByteArray) {
+ event("PUT_ENTER $key sha256=${digest(data)}" + if (key == "SETTINGS") " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(data.decodeToString()).settings.selectedCurrency else "")
+ try { hold("put:${gateKey(key)}") }
+ finally { hold("cleanup:${gateKey(key)}", drain = true) }
+ }
+ fun digest(data: ByteArray) = MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) }
+}
diff --git a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
new file mode 100644
index 0000000..82f5ad2
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -0,0 +1,114 @@
+// test hook: shell-only control surface for disposable emulator.
+package to.bitkit.dev
+
+import android.app.Activity
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.widget.TextView
+import android.widget.ScrollView
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.CacheStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.backup.VssBackupClient
+import to.bitkit.models.BackupCategory
+import to.bitkit.models.BackupItemStatus
+import to.bitkit.repositories.BackupRepo
+import to.bitkit.services.UsdtService
+import to.bitkit.testhook.BackupFault
+import java.util.concurrent.ConcurrentHashMap
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface BackupFaultDependencies {
+ fun backup(): BackupRepo
+ fun cache(): CacheStore
+ fun settings(): SettingsStore
+ fun keychain(): Keychain
+ fun vss(): VssBackupClient
+ fun usdt(): UsdtService
+}
+
+class BackupFaultProvider : ContentProvider() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val tasks = ConcurrentHashMap<String, Job>()
+ private val deps by lazy { EntryPointAccessors.fromApplication(requireNotNull(context), BackupFaultDependencies::class.java) }
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ check(Binder.getCallingUid() == 2000)
+ fun asyncTask(name: String, block: suspend () -> Unit) {
+ tasks[name] = scope.launch {
+ BackupFault.event("TASK_BEGIN $name")
+ try { block(); BackupFault.event("TASK_OK $name") }
+ catch (e: Exception) { BackupFault.event("TASK_ERROR $name ${e.javaClass.simpleName}") }
+ }
+ }
+ val answer = when(method) {
+ "init" -> { asyncTask("init") {
+ check(deps.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name) == null)
+ deps.keychain().saveString(Keychain.Key.BIP39_MNEMONIC.name, org.lightningdevkit.ldknode.generateEntropyMnemonic(org.lightningdevkit.ldknode.WordCount.WORDS12))
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "prepare" -> { asyncTask("prepare") {
+ BackupFault.releaseAll(); deps.backup().setWiping(false); deps.backup().reset()
+ deps.settings().update { it.copy(selectedCurrency = "USD") }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "cycle" -> { asyncTask("cycle") { repeat(5) { deps.backup().stopObservingBackups(); deps.backup().startObservingBackups(); delay(500) } }; "started" }
+ "start" -> { deps.backup().startObservingBackups(); BackupFault.event("START_REQUEST"); "ok" }
+ "stop" -> { deps.backup().stopObservingBackups(); BackupFault.event("STOP_REQUEST"); "ok" }
+ "arm" -> { BackupFault.arm(requireNotNull(arg)); "ok" }
+ "release" -> { BackupFault.release(requireNotNull(arg)); "ok" }
+ "currency" -> { asyncTask("currency") { deps.settings().update { it.copy(selectedCurrency = requireNotNull(arg)) } }; "started" }
+ "mark" -> { asyncTask("mark") { deps.cache().updateBackupStatus(BackupCategory.valueOf(requireNotNull(arg))) { it.copy(required = it.required + 1) } }; "started" }
+ "manual" -> { asyncTask("manual") { deps.backup().triggerBackup(BackupCategory.valueOf(requireNotNull(arg))).getOrThrow() }; "started" }
+ "full" -> { deps.backup().scheduleFullBackup(); BackupFault.event("FULL_REQUEST"); "ok" }
+ "usdt" -> { asyncTask("usdt") { deps.usdt().recoveryBackup.persist("disposable-synthetic-same-operation") }; "started" }
+ "reset" -> { asyncTask("reset") { deps.backup().reset() }; "started" }
+ "cancel" -> { tasks[arg]?.cancel(); BackupFault.event("CANCEL $arg"); "ok" }
+ "wipe" -> { deps.backup().setWiping(arg == "true"); BackupFault.event("WIPE $arg"); "ok" }
+ "clear" -> { BackupFault.events.clear(); "ok" }
+ "fetch" -> { asyncTask("fetch") { val item = deps.vss().getObject(requireNotNull(arg)).getOrThrow(); BackupFault.event("FETCH $arg present=${item != null} sha256=${item?.value?.let(BackupFault::digest)}" + if (arg == "SETTINGS" && item != null) " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(item.value.decodeToString()).settings.selectedCurrency else "") }; "started" }
+ "status" -> runBlocking(Dispatchers.IO) {
+ "ack=" + deps.usdt().recoveryGeneration + " currency=" + deps.settings().data.first().selectedCurrency + " statuses=" + deps.cache().backupStatuses.first().filterKeys { it.name == (arg ?: "SETTINGS") }.entries.joinToString { "${it.key}=${it.value}" }
+ }
+ else -> BackupFault.events.joinToString("\n")
+ }
+ if (method == "status") BackupFault.event(answer)
+ return Bundle().apply { putString("result", answer) }
+ }
+ override fun onCreate() = true
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
+ override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
+ override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
+}
+
+class BackupFaultActivity : Activity() {
+ private val handler = Handler(Looper.getMainLooper())
+ private lateinit var text: TextView
+ private val refresh = object : Runnable {
+ override fun run() {
+ text.text = "Backup lifecycle fault fixture\nPR source + local hold/log hooks\nReal DataStore and native encrypted VSS\nSynthetic recovery payload; no funds\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ handler.postDelayed(this, 250)
+ }
+ }
+ override fun onCreate(saved: Bundle?) {
+ super.onCreate(saved)
+ text = TextView(this).apply { setTextColor(Color.WHITE); setBackgroundColor(Color.BLACK); textSize = 14f; setPadding(24,60,24,20) }
+ setContentView(ScrollView(this).apply { addView(text) }); handler.post(refresh)
+ }
+ override fun onDestroy() { handler.removeCallbacks(refresh); super.onDestroy() }
+}The transport fault fixture:
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
from urllib.request import Request,urlopen
from urllib.error import HTTPError
from pathlib import Path
import threading,time
lock=threading.Lock()
state={'fail':0}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args): pass
def do_GET(self): self.relay()
def do_POST(self): self.relay()
def relay(self):
body=self.rfile.read(int(self.headers.get('Content-Length','0')))
if self.path=='/control/fail':
with lock: state['fail']=1000
self.send_response(200); self.end_headers(); self.wfile.write(b'armed'); return
if self.path=='/control/healthy':
with lock: state['fail']=0
self.send_response(200); self.end_headers(); self.wfile.write(b'healthy'); return
with lock:
fail=state['fail']>0 and self.command=='POST' and 'put' in self.path.lower()
if fail: state['fail']-=1
if fail:
self.send_response(409); self.send_header('Content-Type','application/octet-stream'); self.end_headers(); self.wfile.write(b'\x08\x01\x12\x13controlled conflict');print(time.time(),'FAULT_409',self.command,self.path,flush=True); return
headers={k:v for k,v in self.headers.items() if k.lower() not in ('host','connection','accept-encoding')}
req=Request('https://bitkit.stag0.blocktank.to/vss_rs_auth'+self.path,data=body if self.command=='POST' else None,headers=headers,method=self.command)
try: result=urlopen(req,timeout=90)
except HTTPError as e: result=e
except Exception as e:
self.send_response(502);self.end_headers();self.wfile.write(type(e).__name__.encode());print('PROXY_ERROR',type(e).__name__,flush=True);return
data=result.read();self.send_response(result.status)
for k,v in result.headers.items():
if k.lower() not in ('connection','transfer-encoding','content-length','content-encoding'): self.send_header(k,v)
self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data)
print(time.time(),self.command,self.path.split('?')[0],result.status,flush=True)
ThreadingHTTPServer(('127.0.0.1',24889),Handler).serve_forever()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
|
@piotr-iohk Resolved the parent conflict with signed additive merge c255458, preserving both connection-correction tests and lifecycle ownership. Final 4,681 unit/component tests, full compile/format/Detekt/Android Lint passed with zero findings; two independent final composition reviews were clean. All three commits in the live-parent range are locally verified and GitHub true/valid. New-head hosted checks are pending; no new authenticated composition journey is claimed. |
|
Published follow-up 49e9a99 retains explicit backup retry ownership after observation stops and reserves each queued USDT callback’s exact required revision before cancellation can interrupt its category wait. Older completions/timers preserve newer pending revisions and backoff. Controlled fail-before cases pass after repair; final 4,696 tests/255 suites (112 backup cases), full format/compile/Detekt/Android Lint and two consecutive independent final reviews pass. All five live-base commits verify locally and on GitHub. New-head hosted checks are pending; manual case 5/authenticated fault journeys remain unrun, and backup success does not acknowledge an earlier failed Core callback or submit a payment. Updated QA evidence preserves the prior head’s five failed E2E executions before eventual success. Requested Ovi’s affected re-review once; existing João/Piotr requests remain. |
jvsena42
left a comment
There was a problem hiding this comment.
Code scan of a102e0e and 49e9a99: one LOW inline (a stale failedBackupRequired after a cancelled USDT reservation). No HIGH or MEDIUM.
Checked and clean:
- No retry storm: every retry path goes through
retryAfterTemporaryFailure's backoff (10 s doubling to a 5-minute cap) or a single reschedule after a data change;backupJobsdedupes owner and explicit jobs. - Nothing is marked synced before it is durable:
syncedadvances only inonSuccessafterputObject, and cancelled callbacks never count as acknowledgements. - The transport classifier needs the request-error prefix, an http(s) URL with a host and the
/putObjectspath; HTTP status, decode, auth and cancellation stay permanent. reset()cancels and joinspendingScopebefore clearing explicit backups, andscheduleBackuprefuses while wiping.- The master merge's conflicts in
BackupRepo.ktandBackupRepoTest.ktkept the PR'striggerBackupand added master's test; nothing dropped. - Twin synonymdev/bitkit-ios#1123 has no
failedBackupRequired, so the LOW doesn't apply there.
Performance-relevant: explicit retries now continue after observation stops.
Device gate (partial): 49e9a99 — not run yet; pending the background explicit-retry cadence (Retrying backup for: / Backup starting for: gaps 10, 20, 40, 80, 160, 300 s; at most 8 categories after scheduleFullBackup) with VSS unreachable, and whether the real binding reports error sending request for url (…/putObjects). Device results will follow in a separate review.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — a cancelled recovery reservation can leave observation unable to back up the newer revision.
Findings: LOW — stale permanent-failure marker blocks newer recovery work, app/src/main/java/to/bitkit/repositories/BackupRepo.kt:649.
I corroborated this existing finding on device. After a permanent failure at revision R, a callback cancelled after durably reserving R2 leaves the old failure entry. Observation admits R2, but the later containsKey gates at lines 550 and 583 refuse upload. With transport healthy, R2 stayed pending without a recovery PUT; a later explicit callback succeeded. Clearing the superseded failure should restore observation-owned work without creating an explicit retry owner or acknowledging Core.
The composed head also retains two already-raised parent issues:
- MEDIUM:
app/src/main/java/to/bitkit/ui/ContentView.kt:424hides Proceed after an ordinary startup failure even with clear required restore markers. Existing finding. - LOW:
app/src/main/java/to/bitkit/viewmodels/WalletViewModel.kt:601and:635report the same startup error twice. Existing finding.
The parent's 8d72053 repair is absent from this head.
QA for 49e9a99: Android 15, API 35, arm64, disposable unfunded installation. I built the unmodified head, then drove the build with the fault controls below with real app DataStores and LNURL-authenticated encrypted VSS. Recovery cases use the native empty-wallet export and real app callback; they do not establish signed-payment recovery or a deployed-contract outage journey.
Test 1: pending changes across restart ✅ Edits and a due retry waited for old cleanup, then uploaded without another edit.
I held WIDGETS cancellation cleanup, requested restart, edited synchronized settings to EUR, and let a BLOCKTANK retry become due. Replacement setup and uploads waited for release. Decrypted VSS readback contained EUR and matched the upload digest.
case1-2x.2x.mp4
Restart drain and decrypted settings readback, 2×.
Test 2: reset drains manual and recovery persistence ✅ Cancelled reset callers still waited, and cancelled recovery was not acknowledged.
Both VSS clients reset only after the held manual or recovery write returned. The app acknowledgement counter stayed unchanged after cancellation and advanced once on explicit callback retry with observation stopped. The recovery writes used identical content-addressed keys and payload digests. These states were checked in the app logs shown by the diagnostic screen.
case2-2x.2x.mp4
Reset cancellation and independent recovery retry, 2×.
Test 3: wipe admission ✅ Manual, full and recovery requests produced no upload or status change during wipe.
I held wipe admission closed after reset. SETTINGS and USDT recovery status snapshots remained unchanged; after release, explicit recovery completed with observation stopped. These assertions compare app status reads and upload logs.
case3-2x.2x.mp4
Wipe admission and stopped-observer recovery, 2×.
Test 4: full-backup follow-up ✅ New revisions and requests behind cancelled cleanup resumed without another mutation.
I drove the shared full-backup scheduler through SETTINGS. A change during the first upload caused a successor upload; a consumed settings change survived a held cache write across stop/restart; a full request during stopped-job cleanup completed independently. Final decrypted VSS readback contained CHF. This does not drive complete WALLET capture.
case4-2x.2x.mp4
Revision follow-up and required-status persistence, 2×.
Test 5a: explicit retry with observation stopped ✅ Native request-send failures retried and recovered without another edit.
A transport control closed PUT connections, producing the released native UnknownException. Manual SETTINGS and USDT recovery each used 10-second then 20-second retry delays, followed by the normal 5-second scheduler debounce, and recovered when transport resumed. Automatic recovery backup success left the failed callback unacknowledged; only an explicit successful callback retry advanced the app acknowledgement counter. Recovery attempts retained the same native empty-wallet envelope digest and key. Timings, payloads and acknowledgements were checked in app logs.
case5a-2x.2x.mp4
Native connection failures, backoff and callback acknowledgement, 2×.
Test 5b: cancelled queued recovery revision ✅ An older upload could not synchronize or acknowledge the cancelled newer callback.
While an older USDT upload was held, the queued callback reserved a higher required revision before its category wait. I cancelled it, released the older upload, and read synced at the old revision with the newer revision still pending. A later explicit callback retry synchronized its own revision and advanced the acknowledgement counter. These assertions were checked in app logs.
case5b-2x.2x.mp4
Queued callback cancellation and successor revision, 2×.
Test 6: cancelled reservation after permanent failure ❌ Observation left a newer recovery revision pending behind the older failure marker.
A native VSS InvalidData response supplied the permanent failure. I restored transport, held the next callback after its real DataStore reservation committed, cancelled it, released the hold, and started observation. After the normal debounce, no recovery PUT occurred and the newer revision remained pending. A later explicit callback uploaded successfully and advanced its acknowledgement, confirming transport was healthy. This checks observation-owned backup liveness; it does not assert that leaving a busy payment screen cancels its callback.
case6-2x.2x.mp4
Permanent failure, cancelled reservation, blocked observation and successful explicit retry; 2×. Revision and acknowledgement assertions were checked in app logs.
Test 7: full stopped-observer backoff ✅ Retry delays reached the five-minute cap and recovery respected the scheduled delay.
With observation stopped, native VSS conflicts kept SETTINGS pending through delays of 10, 20, 40, 80, 160 and 300 seconds. App timestamps confirmed each subsequent attempt also waited for the normal five-second debounce. Restoring transport after the capped delay was scheduled did not bypass it; the next permitted attempt synchronized SETTINGS and decrypted readback succeeded. Timing was checked from app logs. The recording spans the full wait in consecutive segments.
case7-2x.2x.mp4
Real-time exponential backoff and final recovery; 2×.
Check 1: build and focused regressions ✅ assembleDevDebug and the unmodified-head BackupRepoTest and WalletConnectionRecoveryTest suites passed. I inspected their JUnit reports and complete build log. The stale-timer and cancellation policies have component coverage; Test 7 also measured the complete capped retry cadence on device. Code inspection bounds full scheduling to the seven non-Lightning categories in this head; I did not drive a simultaneous outage of every category.
Earlier evidence: The prior lifecycle review proves the idle restart timing at e585fcc; I do not claim a new timing comparison here. The parent connection-editor report and optional-identity report remain scoped to their recorded partial journeys. Their parent implementations are unchanged in this composition.
Coverage: Contiguous delta e585fcc..49e9a99, all 58 changed files and reached backup, restore, startup, wipe, SDK-lock, subscription and acknowledgement paths. All planned backup fixture cases ran; Test 6 failed as described above. Device full-backup checks used SETTINGS; complete WALLET capture, signed-payment recovery, and the inherited connection-editor and optional-identity journeys were not newly driven. Existing complete-backup availability limitations remain as documented in the PR.
Twin: bitkit-ios#1123 fix: serialize backup observation lifecycle matches the pending-change, cancellation-drain and native request-send retry intent. Android retains explicit retry ownership independently of observation; iOS serializes asynchronous shutdown and pending scheduling.
Uncommitted fault controls
The controls hold setup, upload, acknowledgement and cache-write boundaries, invoke the real repositories, and show their results. Test 6 adds a hold after the real DataStore write returns. The transport proxy forwards encrypted VSS traffic to staging and can close PUT connections or return a controlled conflict or invalid-request response. Neither replaces SDK results nor changes backup decisions. For the first case the same display refreshed on a timer; subsequent recordings update its text only when events change. The app's upload and retry decisions are unchanged in every build.
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index b88fe73a7..c1aa86be7 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -11,5 +11,8 @@
android:authorities="${applicationId}.devtools"
android:exported="true"
tools:ignore="ExportedContentProvider" />
+ <!-- test hook -->
+ <provider android:name="to.bitkit.dev.BackupFaultProvider" android:authorities="${applicationId}.backupfault" android:exported="true" tools:ignore="ExportedContentProvider" />
+ <activity android:name="to.bitkit.dev.BackupFaultActivity" android:exported="true" />
</application>
</manifest>
diff --git a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
new file mode 100644
index 000000000..d3e59df35
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -0,0 +1,115 @@
+// test hook: shell-only control surface for disposable emulator.
+package to.bitkit.dev
+
+import android.app.Activity
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.widget.TextView
+import android.widget.ScrollView
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.CacheStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.backup.VssBackupClient
+import to.bitkit.models.BackupCategory
+import to.bitkit.models.BackupItemStatus
+import to.bitkit.repositories.BackupRepo
+import to.bitkit.services.UsdtService
+import to.bitkit.testhook.BackupFault
+import java.util.concurrent.ConcurrentHashMap
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface BackupFaultDependencies {
+ fun backup(): BackupRepo
+ fun cache(): CacheStore
+ fun settings(): SettingsStore
+ fun keychain(): Keychain
+ fun vss(): VssBackupClient
+ fun usdt(): UsdtService
+}
+
+class BackupFaultProvider : ContentProvider() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val tasks = ConcurrentHashMap<String, Job>()
+ private val deps by lazy { EntryPointAccessors.fromApplication(requireNotNull(context), BackupFaultDependencies::class.java) }
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ check(Binder.getCallingUid() == 2000)
+ fun asyncTask(name: String, block: suspend () -> Unit) {
+ tasks[name] = scope.launch {
+ BackupFault.event("TASK_BEGIN $name")
+ try { block(); BackupFault.event("TASK_OK $name") }
+ catch (e: Exception) { BackupFault.event("TASK_ERROR $name ${e.javaClass.simpleName}") }
+ }
+ }
+ val answer = when(method) {
+ "init" -> { asyncTask("init") {
+ check(deps.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name) == null)
+ deps.keychain().saveString(Keychain.Key.BIP39_MNEMONIC.name, org.lightningdevkit.ldknode.generateEntropyMnemonic(org.lightningdevkit.ldknode.WordCount.WORDS12))
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "prepare" -> { asyncTask("prepare") {
+ BackupFault.releaseAll(); deps.backup().setWiping(false); deps.backup().reset()
+ deps.settings().update { it.copy(selectedCurrency = "USD") }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "cycle" -> { asyncTask("cycle") { repeat(5) { deps.backup().stopObservingBackups(); deps.backup().startObservingBackups(); delay(500) } }; "started" }
+ "start" -> { deps.backup().startObservingBackups(); BackupFault.event("START_REQUEST"); "ok" }
+ "stop" -> { deps.backup().stopObservingBackups(); BackupFault.event("STOP_REQUEST"); "ok" }
+ "arm" -> { BackupFault.arm(requireNotNull(arg)); "ok" }
+ "release" -> { BackupFault.release(requireNotNull(arg)); "ok" }
+ "currency" -> { asyncTask("currency") { deps.settings().update { it.copy(selectedCurrency = requireNotNull(arg)) } }; "started" }
+ "mark" -> { asyncTask("mark") { deps.cache().updateBackupStatus(BackupCategory.valueOf(requireNotNull(arg))) { it.copy(required = it.required + 1) } }; "started" }
+ "manual" -> { asyncTask("manual") { deps.backup().triggerBackup(BackupCategory.valueOf(requireNotNull(arg))).getOrThrow() }; "started" }
+ "full" -> { deps.backup().scheduleFullBackup(); BackupFault.event("FULL_REQUEST"); "ok" }
+ "usdt" -> { asyncTask(arg ?: "usdt") { deps.usdt().wallet(requiresNetwork = false); val snapshot = requireNotNull(deps.usdt().backupSnapshot()); BackupFault.event("RECOVERY_SNAPSHOT ${BackupFault.digest(snapshot.encodeToByteArray())}"); deps.usdt().recoveryBackup.persist(snapshot) }; "started" }
+ "reset" -> { asyncTask("reset") { deps.backup().reset() }; "started" }
+ "cancel" -> { tasks[arg]?.cancel(); BackupFault.event("CANCEL $arg"); "ok" }
+ "wipe" -> { deps.backup().setWiping(arg == "true"); BackupFault.event("WIPE $arg"); "ok" }
+ "clear" -> { BackupFault.events.clear(); "ok" }
+ "fetch" -> { asyncTask("fetch") { val item = deps.vss().getObject(requireNotNull(arg)).getOrThrow(); BackupFault.event("FETCH $arg present=${item != null} sha256=${item?.value?.let(BackupFault::digest)}" + if (arg == "SETTINGS" && item != null) " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(item.value.decodeToString()).settings.selectedCurrency else "") }; "started" }
+ "status" -> runBlocking(Dispatchers.IO) {
+ "ack=" + deps.usdt().recoveryGeneration + " currency=" + deps.settings().data.first().selectedCurrency + " statuses=" + deps.cache().backupStatuses.first().filterKeys { it.name == (arg ?: "SETTINGS") }.entries.joinToString { "${it.key}=${it.value}" }
+ }
+ else -> BackupFault.events.joinToString("\n")
+ }
+ if (method == "status") BackupFault.event(answer)
+ return Bundle().apply { putString("result", answer) }
+ }
+ override fun onCreate() = true
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
+ override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
+ override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
+}
+
+class BackupFaultActivity : Activity() {
+ private val handler = Handler(Looper.getMainLooper())
+ private lateinit var text: TextView
+ private val refresh = object : Runnable {
+ override fun run() {
+ val output = "Backup lifecycle fault fixture\n49e9a99 + local hold/log hooks\nReal DataStore and native encrypted VSS\nSynthetic recovery payload; no funds\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ if (text.text.toString() != output) text.text = output // test hook
+ handler.postDelayed(this, 250)
+ }
+ }
+ override fun onCreate(saved: Bundle?) {
+ super.onCreate(saved)
+ text = TextView(this).apply { setTextColor(Color.WHITE); setBackgroundColor(Color.BLACK); textSize = 14f; setPadding(24,60,24,20) }
+ setContentView(ScrollView(this).apply { addView(text) }); handler.post(refresh)
+ }
+ override fun onDestroy() { handler.removeCallbacks(refresh); super.onDestroy() }
+}
diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt
index 9966a479f..a0f47d5ac 100644
--- a/app/src/main/java/to/bitkit/data/CacheStore.kt
+++ b/app/src/main/java/to/bitkit/data/CacheStore.kt
@@ -38,7 +38,9 @@ class CacheStore internal constructor(
val backupStatuses: Flow<Map<BackupCategory, BackupItemStatus>> = data.map { it.backupStatuses }
suspend fun update(transform: (AppCacheData) -> AppCacheData) {
+ to.bitkit.testhook.BackupFault.hold("cache") // test hook
store.updateData(transform)
+ to.bitkit.testhook.BackupFault.hold("cacheAfter", drain = true) // test hook
}
suspend fun addPaidOrder(orderId: String, txId: String) {
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
index eef9af0b1..4d59ab2d0 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
@@ -1,5 +1,6 @@
package to.bitkit.data.backup
+import to.bitkit.testhook.BackupFault // test hook
import com.synonym.vssclient.KeyVersion
import com.synonym.vssclient.VssItem
import com.synonym.vssclient.vssDelete
@@ -36,6 +37,8 @@ class VssBackupClient @Inject constructor(
private val setupMutex = Mutex()
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ BackupFault.event("SETUP_ENTER") // test hook
+ BackupFault.hold("setup", drain = true) // test hook
setupMutex.withLock {
val gate = isSetup
runCatching {
@@ -109,6 +112,7 @@ class VssBackupClient @Inject constructor(
}
fun reset() {
+ BackupFault.event("RESET_VSS") // test hook
synchronized(this) {
isSetup.cancel()
isSetup = CompletableDeferred()
@@ -122,6 +126,7 @@ class VssBackupClient @Inject constructor(
data: ByteArray,
): Result<VssItem> = withContext(ioDispatcher) {
isSetup.await()
+ BackupFault.beforePut(key, data) // test hook
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runCatching {
vssStore(
@@ -129,8 +134,11 @@ class VssBackupClient @Inject constructor(
value = data,
)
}.onSuccess {
+ BackupFault.event("PUT_OK $key sha256=${BackupFault.digest(data)}") // test hook
+ BackupFault.hold("post:${BackupFault.gateKey(key)}", drain = true) // test hook
Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG)
}.onFailure {
+ BackupFault.event("PUT_ERROR $key ${it.javaClass.simpleName}") // test hook
Logger.verbose("VSS 'putObject' error for '$key'", it, context = TAG)
}
}
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 7842dbf19..7c6472c57 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClientLdk.kt
@@ -43,6 +43,7 @@ class VssBackupClientLdk @Inject constructor(
private val setupMutex = Mutex()
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.event("LDK_SETUP_ENTER") // test hook
setupMutex.withLock {
val gate = isSetup
runCatching {
@@ -75,6 +76,7 @@ class VssBackupClientLdk @Inject constructor(
}
fun reset() {
+ to.bitkit.testhook.BackupFault.event("RESET_LDK") // test hook
synchronized(this) {
isSetup.cancel()
isSetup = CompletableDeferred()
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
index 09797720d..6bd811c54 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -111,7 +111,7 @@ internal object Env {
val vssStoreIdPrefix get() = "bitkit_v1_${network.name.lowercase()}"
val vssServerUrl
- get() = when (network) {
+ get() = if (to.bitkit.BuildConfig.DEBUG) "http://127.0.0.1:24889" else when (network) { // test hook
Network.BITCOIN -> "https://bitkit.to/vss_rs_auth"
else -> "https://bitkit.stag0.blocktank.to/vss_rs_auth"
}
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index b06ed2e9a..1975918bc 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -833,6 +833,7 @@ class BackupRepo @Inject constructor(
temporaryFailureSince.putIfAbsent(category, backupRequired)
val retryDelay = (TEMPORARY_FAILURE_RETRY_DELAY * (1 shl (attempt - 1).coerceAtMost(MAX_RETRY_SHIFT)))
.coerceAtMost(TEMPORARY_FAILURE_RETRY_MAX_DELAY)
+ to.bitkit.testhook.BackupFault.event("RETRY $category delay=$retryDelay revision=$backupRequired") // test hook
Logger.warn("Retrying backup for: '$category' in '$retryDelay'", e, context = TAG)
pendingScope.launch(start = CoroutineStart.LAZY) {
delay(retryDelay)
diff --git a/app/src/main/java/to/bitkit/testhook/BackupFault.kt b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
new file mode 100644
index 000000000..f99eb5b6c
--- /dev/null
+++ b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
@@ -0,0 +1,40 @@
+// test hook: local review fixture, never shipped.
+package to.bitkit.testhook
+
+import android.util.Log
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import to.bitkit.BuildConfig
+
+object BackupFault {
+ private val gates = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
+ val events = CopyOnWriteArrayList<String>()
+ fun event(text: String) {
+ if (!BuildConfig.DEBUG) return
+ val line = "${android.os.SystemClock.elapsedRealtime()} $text"
+ events.add(line)
+ Log.i("BackupFault", line)
+ }
+ fun arm(name: String) { gates[name] = CompletableDeferred(); event("ARM $name") }
+ fun release(name: String) { gates.remove(name)?.complete(Unit); event("RELEASE $name") }
+ fun releaseAll() { gates.keys.toList().forEach { release(it) } }
+ suspend fun hold(name: String, drain: Boolean = false) {
+ if (!BuildConfig.DEBUG) return
+ val gate = gates[name] ?: return
+ event("HELD $name")
+ if (drain) withContext(NonCancellable) { gate.await() } else gate.await()
+ gates.remove(name, gate)
+ event("EXIT $name")
+ }
+ fun gateKey(key: String) = if (key.startsWith("USDT_RECOVERY_")) "USDT_RECOVERY" else key
+ suspend fun beforePut(key: String, data: ByteArray) {
+ event("PUT_ENTER $key sha256=${digest(data)}" + if (key == "SETTINGS") " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(data.decodeToString()).settings.selectedCurrency else "")
+ try { hold("put:${gateKey(key)}") }
+ finally { hold("cleanup:${gateKey(key)}", drain = true) }
+ }
+ fun digest(data: ByteArray) = MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) }
+}
Transport control:
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
from urllib.request import Request,urlopen
from urllib.error import HTTPError
from pathlib import Path
import threading,time
lock=threading.Lock()
state={'fail':0,'drop':False,'permanent':False}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args): pass
def do_GET(self): self.relay()
def do_POST(self): self.relay()
def relay(self):
body=self.rfile.read(int(self.headers.get('Content-Length','0')))
if self.path=='/control/fail':
with lock: state['fail']=1000; state['drop']=False
self.send_response(200); self.end_headers(); self.wfile.write(b'armed'); return
if self.path=='/control/drop':
with lock: state['drop']=True; state['fail']=0
self.send_response(200); self.end_headers(); self.wfile.write(b'drop armed'); return
if self.path=='/control/permanent':
with lock: state['permanent']=True; state['drop']=False; state['fail']=0
self.send_response(200); self.end_headers(); self.wfile.write(b'permanent armed'); return
if self.path=='/control/healthy':
with lock: state['fail']=0; state['drop']=False; state['permanent']=False
self.send_response(200); self.end_headers(); self.wfile.write(b'healthy'); return
with lock:
fail=state['fail']>0 and self.command=='POST' and 'put' in self.path.lower()
if fail: state['fail']-=1
if state['permanent'] and self.command=='POST' and 'put' in self.path.lower():
self.send_response(400); self.send_header('Content-Type','application/octet-stream'); self.end_headers(); self.wfile.write(b'\x08\x02\x12\x17controlled invalid data');print(time.time(),'FAULT_PERMANENT',self.command,self.path,flush=True); return
if state['drop'] and self.command=='POST' and 'put' in self.path.lower():
self.close_connection=True; print(time.time(),'FAULT_DROP',self.command,self.path,flush=True); return
if fail:
self.send_response(409); self.send_header('Content-Type','application/octet-stream'); self.end_headers(); self.wfile.write(b'\x08\x01\x12\x13controlled conflict');print(time.time(),'FAULT_409',self.command,self.path,flush=True); return
headers={k:v for k,v in self.headers.items() if k.lower() not in ('host','connection','accept-encoding')}
req=Request('https://bitkit.stag0.blocktank.to/vss_rs_auth'+self.path,data=body if self.command=='POST' else None,headers=headers,method=self.command)
try: result=urlopen(req,timeout=90)
except HTTPError as e: result=e
except Exception as e:
self.send_response(502);self.end_headers();self.wfile.write(type(e).__name__.encode());print('PROXY_ERROR',type(e).__name__,flush=True);return
data=result.read();self.send_response(result.status)
for k,v in result.headers.items():
if k.lower() not in ('connection','transfer-encoding','content-length','content-encoding'): self.send_header(k,v)
self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data)
print(time.time(),self.command,self.path.split('?')[0],result.status,flush=True)
ThreadingHTTPServer(('127.0.0.1',24889),Handler).serve_forever()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
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: full review of the complete PR diff against its merge base 0fd5e42 (the #1686 head), at 49e9a99. Covered BackupRepo lifecycle ownership (reset drain, stop/restart observation, generation fence, per-category serialization, explicit retry ownership, USDT revision reservation, native request-send classifier), its callers (WipeWalletUseCase, BackupsViewModel, WalletViewModel.scheduleFullBackup, UsdtService → Core USDT persist callback), the updated BackupRepoTest and the journey doc.
1 actionable finding — resolve or provide an evidence-backed rebuttal.
The new finding comes from the latest commit: explicit USDT recovery persistence now uses the per-category lock, which a scheduled backup holds through its 5 s debounce. Paykit USDT payments arm that scheduled backup just before Core's sign → persist → broadcast sequence. Core-side behaviour was checked against bitkit-core v0.8.0-rc11 (wallet.rs send_with_deadline/persist_backup, backup.rs export_backup): there is no deadlock, but broadcast is delayed. The iOS twin bitkit-ios#1123 @ 2416046 runs its debounce outside per-category admission and does not serialize recovery persistence behind it. The native request-send classifier and drain intent match iOS.
Existing unresolved concern, verified still present at this head: a cancelled USDT reservation after a permanent failure leaves a stale failedBackupRequired marker that the new containsKey gates then refuse. The reservation at BackupRepo.kt:647-654 does not clear the older marker, unlike markBackupRequired/performBackup. The earlier restart-drops-settings thread mechanism no longer holds: replacement listeners start before the drain. The parent-branch concerns raised on #1685 are outside this diff and belong to the dependency.
Validation: no local build or test run (no Android SDK in this environment). Head CI build/unit tests and lint passed. The E2E run passed after internal retries, per the author's report. Conclusions here are from source analysis. Device testing: not performed in this review.
Recommended before device testing: resolve the stale-marker thread above and the debounce finding below.
Suggested additional test cases
- Android, unit (
BackupRepoTest): start observation, bumppaykitUsdtPaymentRepo.backupStateVersionso a scheduledUSDT_RECOVERYjob enters its debounce, then callpersistUsdtRecoveryBackup("signed")without advancing virtual time past 5 s. Expected: the persist completes and uploads its snapshot without waiting for the debounce. - Android, regtest/staging Paykit USDT payment request with the node running and VSS reachable: approve and pay. In the logs compare the scheduled
Backup starting for: 'USDT_RECOVERY'timestamp, Core's persist callback and submission. Expected: submission is not delayed by the 5 s scheduler debounce, and only the callback's own recovery upload precedes broadcast.
Findings
- [MEDIUM] Explicit USDT persist waits behind a scheduled job's 5 s debounce — inline at
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:544.
Reviewer model: claude-opus-5-5
Reasoning effort: high
|
Device gate (partial): To make VSS unreachable I used a host DNS block of
Still being checked against master before I call either one PR-related:
Still running: the restart-survival check (a pending backup survives a relaunch and completes once VSS returns). |
jvsena42
left a comment
There was a problem hiding this comment.
Approving. The device gate passed at 49e9a99. My LOW at BackupRepo.kt:649 (a stale failedBackupRequired after a cancelled USDT reservation) stays open: fix it here or in a follow-up issue, your choice; reply on the thread saying which.
Device gate: 49e9a99 (CI APK), Pixel_9 emulator. VSS host DNS-blocked with Electrum up. Compared against master ea89ed1.
- Retry cadence:
in10s, 20s, 40s, 1m 20s, 2m 40s, then 5m capped.- Gaps from
Retryingto the nextBackup startingwere 15, 25, 45, 85, 165 and 305 s (delay plus the 5 s debounce), and each failed attempt took 10.3–10.7 s. - At most 4 categories retried at once.
- Master on the same steps logs one
Backup failedand doesn't retry.
- Gaps from
- Logged error:
UnknownException='errorDetails=error sending request for url (…/vss_rs_auth/putObjects)', classified as temporary. - Restart survival: pending backups survived an emulator reboot,
am force-stopand a native crash, with VSS blocked at both kill and relaunch. A stalerunningflag was cleared on start, and uploads completed 0.5–11 s afterNode startedonce VSS returned. Master also passes these coarse steps; the races this PR fixes need its unit-test fault fixture, which CI covers. - Recovery: every category succeeded within its remaining backoff after VSS returned (41 s to 2 m 36 s).
- Perf: the cadence above, at most 4 categories.
Not from this PR's diff:
- Native crash: a SIGABRT in
libldk_node.so(A Tokio 1.x context was found, but it is being shutdown.) 3–5 ms after a failed//node_metricswrite, when the node stops on backgrounding with VSS unreachable and the cached-apps freezer disabled. It appeared on this head (2 of 2) and on the base branch #1686 (1 of 1), but not on master (0 of 2). I'm following it up on #1686. - Retry stuck until the node starts: after a restart with VSS down, the node can't start, so an explicit Retry logs
Backup startingand then waits, becauseputObjectwaits onisSetup, which only node startup completes. Master has the same wait.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full PR at 8727da02549c3a885fbb48b500747b3401d3d649 after the comparison base changed from b6751e2 to 43e0f3fe04705d397a81f493cf8e57239a0be73e. No coverage baseline was supplied, so this pass covered the whole diff from that merge base, which is also synonymdev/bitkit-android#1686.
Code review completed with unverified earlier concerns.
Observation stop, reset, and wipe keep a pending revision from being acknowledged or dropped: reset joins retained work before replacing VSS clients, explicit retries survive stopped Lightning observation, and a canceled USDT callback does not increment the Core acknowledgement. Cold explicit backups for ordinary categories initialize VSS before upload, keep a setup timeout on the bounded retry path, and refuse failed Blocktank or activity reads instead of uploading empty envelopes. Profile deletion makes one attempt; it no longer refreshes the session and repeats contact cleanup and deletion.
Compared with synonymdev/bitkit-ios#1123 at ff3ba674464c796057415188d460e6009371496a: the narrow VSS request-send classifier matches. Android cancels observation immediately and the next start waits for that cleanup; iOS awaits its shutdown task. iOS still retries profile deletion after a session refresh.
Validation: source and the assertions in BackupRepoTest.kt, PubkyRepoTest.kt, and EditProfileViewModelTest.kt were inspected at this revision. Those tests were not executed here. Device testing was not performed. Historical validation claims in the PR description were not treated as execution by this review.
Suggested additional test cases
- Android, disposable wallet that already has a remote BLOCKTANK or ACTIVITY object, Lightning never started. Tap Retry for that category. Expected: a failed local read leaves the remote object unchanged; a successful read uploads the persisted cache rather than an empty list.
Earlier findings reconciled at this revision: 1 still present, 2 verified fixed, 1 could not be verified, 1 tracked separately outside this PR.
Tracked separately (outside this PR; unresolved): Native LDK shutdown abort during VSS outage. The reported VSS-outage native shutdown abort remains the open ldk-node shutdown failure in #1536. From base 43e0f3f to head 8727da0 this PR does not change LightningService.stop, the ldk-node-android 0.7.0-rc.78 pin, or native LDK sources. The issue stays open. This stage performed no native execution and does not treat the issue as a fix or a current-head pass. This is not a runtime verification or merge-readiness declaration.
Reconciliation incomplete: the following earlier concerns remain unverified. This report does not establish that they are fixed or invalid, or establish approval or device readiness.
Could not verify earlier concern — Signed-in outage recovery encountered Pubky lock/HTTP 405 errors: The HTTP 405 Pubky write-lock failure is an attributed device report at 9cfe052. A later attributed signed-in repeat at a3693b0 completed WALLET and METADATA and did not identify the earlier cause or a code fix. Current source still propagates Paykit snapshot failures and still refuses metadata replacement while a required WALLET backup has quarantined public addresses, but Paykit Transport is classified as temporary, so that source does not show a stuck 405. No current reproduction, native trace, or exact tracking issue was available in this code-only pass, so recurrence and resolution stay unverified.
Existing concerns
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — still unresolved; already tracked.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: ⛔️ Request changes. The cold-start Retry hang is fixed, but an offline Retry now fails permanently, and the category stays failed after the server returns. The existing terminal complete-WALLET availability HIGH stays open as tracked.
Findings:
- MEDIUM: Offline cold-start Retry now fails permanently and blocks the later automatic upload (
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:730).setup()succeeds without a network call; the native client authenticates on the firstputObject. With the backup host unreachable, that put fails withVssException.AuthException(error sending request for url (…/lnurl_auth/auth)).isTemporaryBackupFailuretreats that as permanent.markBackupFailedrecordsfailedBackupRequiredand schedules nothing, andshouldBackupkeeps skipping the category after the server returns and observation restarts.- On device, SETTINGS stayed "Failed Backup" after the host came back and the node started, while ACTIVITY and WIDGETS (never retried) uploaded on their own. It synced only after a second manual Retry.
- Before this commit, the same tap waited and then completed 0.5 s after node start (jvsena42's report).
- Manual 6 at this head saw the same authentication failure. Its second explicit Retry after restoring auth hides the missing automatic retry, which the Description promises: "Retry temporary failures for explicit backup requests even when Lightning observation is stopped, with capped backoff."
- The new
BackupClientSetupTimeoutmapping is never reached in this outage, because setup makes no network call. - Treating a request-send failure inside
AuthExceptionas temporary, like theputObjectsenvelope already is, would restore the bounded retry.
QA for 8727da0: Android 15 (API 35) emulator, devDebug built from the head, fresh unfunded staging wallet. The backup host's HTTPS port (VSS, LNURL auth, LDK backups, RGS, fx) was blocked on the device; Electrum on its own port stayed reachable. This is the same outage shape as the original report.
Backup Retry after a cold start with the backup server refusing connections ❌ Retry ended in about 10 s instead of hanging, but as a permanent failure with no retry scheduled (app logs).
The video shows the Default Unit edit, force-stop, cold start, and Settings at "Failed Backup" with Retry. Tapping Retry shows Running, then Failed Backup with Retry about 10 s later. App logs show the node build failing with KV store setup failed and no Node started during the run. They also show Backup failed for: 'SETTINGS' [AuthException=… lnurl_auth/auth] from the permanent branch, with no Retrying backup line.
case1b.2x.mp4
Backup Retry after a cold start with the backup server not answering ❌ The upload stayed Running for 5 min 12 s, then failed permanently with no retry scheduled (app logs).
With packets dropped instead of refused, app logs show VSS client setup with server immediately after the tap, then putObject hanging. It failed 5 min 12 s later with the same permanent AuthException. The native put bounds this outage, not the new setup-timeout mapping, so the long Running is an observation, not a finding. The video covers the first 2.5 minutes of Running; the screenshot shows the final Failed state.
https://github.com/user-attachments/assets/d0c4322f-c950-433d-9835-d8c75ee57a85
https://github.com/user-attachments/assets/37833734-74f4-44ce-a45e-d01686ef68ed
Pending backups sync once the server is back, and stay synced after a cold start ✅ One Retry synced SETTINGS and it stayed synced across a cold start; before that tap it stayed failed.
After unblocking, the node started in 11 s and observation resumed. ACTIVITY and WIDGETS uploaded on their own, but SETTINGS stayed "Failed Backup" for over 1.5 minutes until tapped (the finding above). One Retry gave Backup succeeded for: 'SETTINGS', and after a force-stop and cold start every row still showed a recent backup.
https://github.com/user-attachments/assets/ec6a0380-f89b-4d19-be43-d97412b9bf97
https://github.com/user-attachments/assets/c8289368-cb94-4126-a0cc-6006d7f49fd9
https://github.com/user-attachments/assets/e9300559-6816-4022-9993-d53782260796
https://github.com/user-attachments/assets/c0f65cf5-fa35-4dbe-ae35-95f925fc13d3
Not driven: Manual 1–5 need held setup, capture and upload controls. Their retained scheduler report at 9a837a6 and the later reports listed in our previous review keep their recorded heads and limits. This delta touches the ordinary upload path they rely on only by adding setup before the put. The BLOCKTANK read from Core and the ACTIVITY read refusal ran in the cold-start uploads above (ACTIVITY synced from a cold start), but no Core read failure was injected. Hosted CI at this head passes.
piotr-iohk's code-only QA review at this head reads the setup timeout as keeping cold Retry on the bounded retry path; the cases above show the outage arriving as an AuthException from the put instead. Its suggested remote BLOCKTANK/ACTIVITY overwrite case was not driven here.
Twin: bitkit-ios#1123 at ff3ba67 already sets up its VSS client lazily inside putObject, so it matches the new Android behaviour (source comparison only).
Review covered the 439b5d0..8727da0 delta. The base merge add2c04 leaves this PR's own patch byte-identical, so review focused on 8727da0. Outside this delta, uploadWalletBackup and UsdtRecoveryBackupStore still let a setup TimeoutCancellationException escape without failure bookkeeping. A category interrupted by process death also shows "Running" with no Retry until the node starts and observation clears it.
Reviewed by claude-opus-5-5-medium
There was a problem hiding this comment.
Verdict: ⛔️ Request changes, only for the existing terminal complete-WALLET availability HIGH. This delta leaves it unchanged, and the PR lists it as Out of Scope. e7fb0ac fixes our offline cold-start Retry finding: a request-send AuthException now takes the bounded retry, and the failed category syncs on its own once the server is back.
Findings: none in this delta.
isTemporaryBackupFailure(app/src/main/java/to/bitkit/repositories/BackupRepo.kt:1607) has one production caller,markBackupFailed. Reset and generation checks still stop retries, and a failed USDT upload still returns failure without acknowledging Core.- The matched text is the one the released native client produces (
error performing external request: error sending request for url (…/lnurl_auth/auth)); the device logs below show it verbatim. Authorization refusals, HTTP status errors and decode errors stay permanent. - Like the existing temporary classes, retries have no count cap; the delay doubles up to 5 min.
QA for e7fb0ac: Android 15 (API 35) emulator, devDebug built from the head, fresh unfunded staging wallet. The backup host's HTTPS port (VSS, LNURL auth, LDK backups, RGS, fx) was blocked on the device; Electrum on its own port stayed reachable. Same outage as in our review of 8727da0. The three cases cover the transport half of Manual 8.
Backup Retry after a cold start with the backup server refusing connections ✅ Retry failed in about 10 s and scheduled retries at 10, 20 and 40 s instead of failing permanently (app logs).
The video shows the Default Unit edit, force-stop, cold start, and Settings at "Failed Backup" with Retry. Tapping Retry shows Running, then Failed Backup. App logs show Retrying backup for: 'SETTINGS' in '10s' [AuthException='errorDetails=error performing external request: error sending request for url (https://bitkit.stag0.blocktank.to/lnurl_auth/auth)'], then the same at 20 s and 40 s, and no Node started.
https://github.com/user-attachments/assets/05b09524-4939-4d25-a6eb-7764ac09a11b
https://github.com/user-attachments/assets/b7969d30-3575-4ddc-82a9-05e073835064
Backup Retry after a cold start with the backup server not answering ✅ After the native put timed out at 5 min 12 s, the failure scheduled a retry, which synced once the host was back (app logs).
With packets dropped, Retry shows Running while the put hangs; the video covers its first 3 minutes and the screenshot shows Failed Backup after the timeout. App logs show the same AuthException followed by Retrying backup for: 'SETTINGS' in '10s'. After unblocking, Backup succeeded for: 'SETTINGS' came 16 s later with no tap.
https://github.com/user-attachments/assets/8e89b7ac-24b3-493d-a018-45ceaebe8f59
https://github.com/user-attachments/assets/3f9c6681-f397-4233-8a3b-e62466406e4b
A failed Settings backup syncs on its own once the server is back, and stays synced after a cold start ✅ Settings went from Failed Backup to "Latest Backup: now" without a second tap, and stayed synced after a cold start.
Continuing from the first case, the host was unblocked at 22:23:48 UTC. The scheduled 40 s retry gave Backup succeeded for: 'SETTINGS' at 22:23:54, with no tap, edit or node start (app logs). After a force-stop and cold start, Data Backups showed every category with a recent backup. In our 8727da0 run, the same state needed a second manual Retry.
https://github.com/user-attachments/assets/f460e8d7-9992-472a-b308-04ccc193d5fe
https://github.com/user-attachments/assets/949b833e-6a85-497f-83da-3cc0837eb6e5
https://github.com/user-attachments/assets/6f3da927-d62d-431f-b03f-5e8a9f8936f2
Not driven: a genuine LNURL-auth refusal staying permanent, because staging cannot refuse authorization on demand. BackupRepoTest covers it (cold explicit authorization denial stays pending without automatic retry), and the classifier keeps authorization was refused: … permanent. Manual 1–5 need held setup, capture and upload controls; the retained scheduler report at 9a837a6 keeps its recorded head and limits. This delta touches none of their paths except the failure classification.
The author's reply reports the same automatic retry acknowledging a pending BLOCKTANK write after only the auth routes reopened. Our SETTINGS cases above agree; that BLOCKTANK run was not repeated here.
Twin: bitkit-ios#1123 at 4e6c06e does not match. Its isTemporaryBackupFailure accepts a request-send failure only on a /putObjects URL, so an LNURL-auth request-send failure stays permanent there. Its VSS client also authenticates on the first putObject, so an offline Retry there would likely stay failed after the server returns (source comparison only).
Review covered the 8727da0..e7fb0ac delta. Carried over from our previous review, outside this delta: uploadWalletBackup and UsdtRecoveryBackupStore still let a setup TimeoutCancellationException escape without failure bookkeeping.
Reviewed by claude-opus-5-5-medium
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at e7fb0ac3. The new temporary classification only covers AuthException whose text is the request-send failure for an http(s) URL. Denials, 401/403 and decode errors stay permanent and still reach the failure toast. Retries back off to 5 min, stop on reset and generation change, and TimeoutCancellationException maps to BackupClientSetupTimeout instead of passing as a cancellation. A failed core read now refuses the upload instead of overwriting the remote backup with empty data. CI green.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up of the full pull request at e7fb0ac. This pass covered the authentication transport-failure classification and its admission into the existing explicit retry gate since 8727da02. Inherited baseline: review 5755359668. Merge base 43e0f3fe is unchanged.
Code review completed with unverified earlier concerns.
AuthException details that are the released vss-client-ng 0.4.0 external-request wrapper around reqwest 0.12.24 error sending request for url (...), or that send-failure text alone, are temporary. A cold explicit backup then retries through the existing failed-revision gate after 10 seconds plus the 5 second debounce, calls setup and put once more, and acknowledges the category when the retry succeeds. Authorization refusal, invalid JWT, HTTP status, and decode failures stay required and are not scheduled again. Unknown errors match only an HTTP(S) /putObjects URL. The iOS twin implements the same split at 11fb65d.
Device testing: not performed in this review. The new BackupRepoTest cases were inspected, not executed. The classifier predicate was checked against the released error text and the twin's accepted and rejected URL examples.
Earlier findings reconciled at this revision: 1 still present, 1 verified fixed, 1 could not be verified, 1 tracked separately outside this PR.
Tracked separately (outside this PR; unresolved): Native LDK shutdown abort during VSS outage. The reported VSS-outage native shutdown abort remains the open ldk-node shutdown failure in #1536. From base 43e0f3f to head e7fb0ac this PR does not change LightningService.stop, the ldk-node-android 0.7.0-rc.78 pin, or native LDK sources. The issue stays open. This stage performed no native execution and does not treat the issue as a fix or a current-head pass. This is not a runtime verification or merge-readiness declaration.
Reconciliation incomplete: the following earlier concerns remain unverified. This report does not establish that they are fixed or invalid, or establish approval or device readiness.
Could not verify earlier concern — Signed-in outage recovery encountered Pubky lock/HTTP 405 errors: The HTTP 405 Pubky write-lock failure is an attributed device report at 9cfe052. A later attributed signed-in repeat at a3693b0 completed WALLET and METADATA and did not identify the earlier cause or a code fix. Current source still propagates Paykit snapshot failures and still refuses metadata replacement while a required WALLET backup has quarantined public addresses. Paykit Transport remains temporary, and the new AuthException classifier only matches VSS request-send text, so neither path shows a stuck HTTP 405. No current reproduction, native trace, or exact tracking issue was available in this code-only pass, so recurrence and resolution stay unverified.
Findings
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — Terminal identity failure blocks newer ordinary WALLET backups
Raised in review discussion; not verified earlier: The WALLET snapshot still throws when Paykit backup state is null and a backup identity exists. That check is identical at the pinned base and head, and identity failure is not classified as a temporary VSS request-send error, so this PR does not introduce the block. The pinned-head review still attributes the open HIGH to parent #1686 at 9ce4302 and to bitkit-ios#1121 discussion r4368930202. A focused issue search found no open synonymdev/bitkit-android /issues/NUMBER for this mechanism, so tracked_separately cannot be completed without inventing an issue. Open publication would require an introduced or worsened introduction, which this unchanged guard does not have. The concern stays unresolved.
Still present at e7fb0ac (first reported at 9a837a6): Complete WALLET capture still rejects a null Paykit snapshot when a saved identity exists, and a failed restoration still keeps that identity while clearing the in-memory key that snapshot export needs. The guard and the identity providers match base 43e0f3f. The new AuthException request-send classifier does not include PaykitException.Identity or the IllegalStateException from this check, so a later ordinary WALLET revision still stops on the same capture. This PR does not introduce or worsen that guard, and no same-repository issue states this mechanism, so another inline finding stays unpublished.
Source. Summary placement: The retained complete-WALLET guard is outside the current PR diff hunks. Still present from the earlier review.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 1a6048a4, the master merge that adds backup ownership across wallet transitions. No findings.
- Ownership: it is captured before any wallet read (
withBackupOperation, and scheduled jobs carry it fromscheduleBackupintowithWallet). - Before upload: the VSS client suspension stops uploads from an old owner.
- Before the status write: every write re-checks
ownsState, local and native generation, inside the DataStore transform.nativeGenerationonly advances inadoptOwner, after the old owner's jobs are cancelled and joined, so an old wallet's backup can't record status against the new wallet. - Pending backups:
markBackupRequiredstays NonCancellable, and a restarted observation installs its listeners before joining the previous one, so a pending backup isn't lost across stop/restart.
Compared with iOS #1123 the contract matches, enforced at admission, upload and status write. Two differences, neither a safety issue:
- On Android, an upload that already completed under the retiring owner can still record
syncedin the old wallet's cache.cacheStore.reset()erases that cache right after. setWiping(true)doesn't stop an already-admitted generic-category upload. It still goes to the old wallet's own store.
CI green apart from one pending job.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes, only for the existing terminal complete-WALLET availability HIGH. The PR lists it as Out of Scope, and this merge leaves the guard at BackupRepo.kt:1120 unchanged. The merge itself works on device: after a reset, each wallet's backups stay its own, and pending edits survive the outage and restarts.
Findings: none in this delta.
startObservingBackupsnow runs one controller that re-arms on each VSS owner (combine(vssClients.owners, isWiping)). Every pending job, explicit request and status write carries aBackupOwner, whichownsStatechecks inside the DataStore transform.adoptOwnerclears the failure and retry maps only after the old owner's admitted work has drained.- Retry timers re-enter
withWallet(expected.native)and stop once the owner changes.backupJobsdedupe compares the native generation and removal compares job identity, so a retired job cannot hide a new wallet's request. resumeUnstartedBackupmoves a coalesced explicit request only when it belongs to the same live owner.
QA for 1a6048a: Android 15 (API 35) emulator, devDebug built from the head, fresh unfunded staging wallets.
Backups after wiping a wallet and creating a new one belong to the new wallet ✅ Restoring each recovery phrase brought back that wallet's own Local Currency (A: EUR, B: GBP), and every backup row then synced.
In the video, wallet A is created and set to EUR. The same EUR edit shows Backup succeeded for: 'SETTINGS' at 12:50:07 UTC in the app logs. After Reset, the new wallet B started at USD and was set to GBP. Its SETTINGS synced at 12:56:48, after Start observing at 12:56:04, with no failed or retried write (logs; the screenshot shows B's Data Backups). The video then shows B restored from its phrase with £ and GBP, another Reset, and A restored from its phrase with € and EUR. It ends on A's Data Backups while rows are still catching up. A UI read 45 s later had every row at "Latest Backup: now". After A's restore, METADATA was refused once (Public address exclusions must be backed up before replacing metadata), then synced 5 s after WALLET did.
The screenshot's Transaction Log row reads "Failed Backup: now". An idle wallet re-uploads ACTIVITY about every 10 s, which also shows in our e7fb0ac logs, so the row flickers between uploads.
The video misses A's Reset, B's creation and GBP edit, B's first Reset and the phrase entry for B's restore (a recorder cap and a lost clip). The logs and screenshot cover that stretch.
https://github.com/user-attachments/assets/baf9c1ba-bea5-4397-af5a-5a444bc7f4ca
https://github.com/user-attachments/assets/f236fd3b-a691-471d-ae58-47953b3e6e2e
Backup Retry after a cold start with the backup server refusing connections, then syncing on its own once it is back ✅ Retry failed as temporary and scheduled a retry. With the server back, ACTIVITY synced 2 s later with no tap, and the pending Settings edit uploaded on the next cold start.
The backup host's HTTPS was refused on the device, and Electrum stayed reachable. A Default Unit edit failed and was retried as temporary: Retrying backup for: 'SETTINGS' in '10s' [UnknownException=…/vss_rs_auth/putObjects] (app logs). The app was force-stopped during that retry. After a cold start, node setup failed (LDK Build error: KV store setup failed) and backup observation did not start. In the video, Transaction Log Retry shows Running, then Failed Backup. The app logs show Retrying backup for: 'ACTIVITY' in '10s' [AuthException=…/lnurl_auth/auth]. The host was unblocked at 13:16:19, and the scheduled retry logged Backup succeeded for: 'ACTIVITY' at 13:16:21 with no tap.
Settings and Widgets kept the "Running" flag from the upload the force-stop interrupted, so their rows showed no Retry. The node did not retry setup after the host came back. This is not from this PR: the stale flag is cleared only when observation starts, and the same running clear sites exist at e7fb0ac and on master (source reading). On the next cold start, the app logged Clearing stale running flag for: 'SETTINGS', and the pending edit uploaded at 13:19:30. Every row then showed a recent backup.
case1-outage-2x.2x.mp4
Not driven: Manual 1–5 need held setup, capture and upload controls. The retained scheduler report at 9a837a6 keeps its recorded head and limits. No case drove an upload that was still in flight across a wallet transition, or a coalesced explicit request cancelled before dispatch. José's re-approval at 1a6048a reads the same ownership checks in source.
Twin: bitkit-ios#1123 at c75acf3 adds the matching wallet-removal ownership change (VssBackupClient.swift, BackupService.swift, AppReset.swift, with coordinator tests). José's comparison finds the contract matches. I did not compare it line by line.
Review covered the e7fb0ac..1a6048a delta: the merge's own BackupRepo.kt resolution and how it uses #1686's VssClientCoordinator. Carried over, outside this PR's lines: uploadWalletBackup (BackupRepo.kt:934) and UsdtRecoveryBackupStore (:31, :35) still let a setup TimeoutCancellationException escape as a cancellation. For WALLET and USDT_RECOVERY, that skips markBackupFailed. The job then reschedules about every 35 s with no backoff, and the failed-backup toast never fires.
Reviewed by claude-opus-5-5-medium
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Re-reviewed the full pull request at 1cf5d39 after the comparison base moved from 43e0f3f to a2c58f24; no coverage baseline was supplied. The merge base is that same parent revision.
Code review completed with unverified earlier concerns.
A replacement backup keeps running only after the original wallet admits the job, so a reservation retired before dispatch does not leave the category stuck after the previous job drains. Explicit retries, including cold settings and USDT recovery while observation is stopped, stay on the retained backup scope, treat only the documented VSS send-failure shapes as temporary, and withhold the Core USDT acknowledgement until that callback's own persist succeeds. Wallet reset runs while VSS admission is suspended, so admitted work is joined before the clients are replaced. Profile deletion calls deleteProfile() once; an uncertain failure does not sign in or start a second deletion. The shared backup contracts were compared with bitkit-ios#1123 at c75acf31. Android's reset and wipe drains and iOS's shutdown boundary differ as described in journeys/backup-restore/wallet-snapshot-consistency.md. bitkit-android#1686 is this merge base, not a separate review.
Device testing: not performed in this review. Hosted unit and Compose, lint, and local E2E succeeded on this head. Those E2E shards do not drive the backup fault fixture; manual cases 1–5 remain the device coverage for held cleanup, reset, and wipe. The backup and profile unit tests were inspected, not executed locally.
Earlier findings reconciled at this revision: 2 still present, 3 verified fixed, 1 could not be verified, 1 tracked separately outside this PR.
Tracked separately (outside this PR; unresolved): Native LDK shutdown abort during VSS outage. The reported VSS-outage native shutdown abort remains the open ldk-node shutdown failure in #1536. From base a2c58f2 to head 1cf5d39 this PR does not change LightningService.stop, the ldk-node-android 0.7.0-rc.78 pin, or native LDK sources. The issue stays open. This stage performed no native execution and does not treat the issue as a fix or a current-head pass. This is not a runtime verification or merge-readiness declaration.
Reconciliation incomplete: the following earlier concerns remain unverified. This report does not establish that they are fixed or invalid, or establish approval or device readiness.
Could not verify earlier concern — Signed-in outage recovery encountered Pubky lock/HTTP 405 errors: The HTTP 405 Pubky write-lock failure is an attributed device report at 9cfe052. A later attributed signed-in repeat at a3693b0 completed WALLET, METADATA, and SETTINGS and said that repeat does not explain the earlier HTTP 405 or identify a code fix. Current source still propagates Paykit snapshot failures and still refuses metadata replacement while a required WALLET backup has quarantined public addresses. Paykit Transport remains temporary, and the AuthException classifier matches only a VSS request-send string, so neither path shows a stuck HTTP 405. No current reproduction, native trace, or exact tracking issue was available in this code-only pass, so recurrence and resolution stay unverified.
Existing concerns
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — still unresolved; already tracked.
- [MEDIUM] WALLET and USDT setup timeout skips backup failure backoff — still unresolved; already tracked.
Reviewer model: grok-4.7
Reasoning effort: high
jvsena42
left a comment
There was a problem hiding this comment.
Re-approving at 1cf5d39. The two merges since my approval (2fa4098, 1cf5d39) bring in #1686's queued-backup status commits and resolve the conflicts in BackupRepo.kt with new code. I reviewed that resolution as code.
Checked and clean:
PendingBackup.admittedis only set whenbackupJobs[category].job === scheduledJob. Every read and write ofbackupJobs/admittedhappens underlifecycleLock, including theupdateBackupStatuslambdas, so the non-volatile field is safe.running = trueis written beforeowner?.ready?.await(). Every path that writes it reaches the NonCancellable recompute, including a job cancelled while it waits forready.updateBackupStatusis gated onownsState, and the generation only advances after the old jobs are joined, so the flag cannot be left stuck across a generation or a restart.- The stale-running clear in
startObservationkeeps the flag for an admitted job and clears it for a reservation that was not admitted. The job's ownshouldBackupcheck leaves outrunning, so it cannot block itself. - No #1686-only symbols (
pendingBackups,backupJobLock) remain.
Device gate: no device check needed. The merge resolution only affects the backup status flag, which #1686's scheduled-backup-status.xml covers; I'm gating that on #1686.
There was a problem hiding this comment.
Verdict: 💬 Comment — the delta checks passed; the existing complete-WALLET availability concern remains unresolved.
Findings: No new findings in this delta. The previously reported complete-WALLET limitation and WALLET/USDT setup-timeout bookkeeping caveat are unchanged.
QA for 1cf5d39: Android 15 / API 35 ARM64 emulator. Built from this head. The queued-status journey used the unmodified app; the controlled cases used the local Debug holds and logs disclosed below, real DataStore and released native VSS encryption/authentication against staging. The signed-operation case used an isolated local USDT fork with test funds. Case sentences below are checked against the recorded event display, app/status logs, native readback or fixture submission counters as stated.
Queued settings backup shows Running until acknowledgement ✅ On the unmodified head build, EUR showed Running with no Retry during debounce, then Latest Backup after acknowledgement. UI and app logs checked.
journeys/backup/scheduled-backup-status.xml: changed Local Currency from USD to EUR and opened Data Backups immediately. The Settings row showed Running without Retry, then Latest Backup. The video includes both states.
scheduled-status-2x.2x.mp4
Unmodified app journey.
Pending settings survive observation restart during cleanup ✅ Held WIDGETS cleanup stopped replacement setup and a due BLOCKTANK retry. A EUR edit stayed pending and uploaded after release; native decrypted readback matched.
Manual 1. WIDGETS cancellation cleanup stayed held while observation restarted. No new setup or upload entered during the hold, including a due BLOCKTANK retry. The EUR edit uploaded after release without another edit; a native VSS read decrypted the matching SETTINGS payload. These assertions are in the event/status logs. An initial fixture attempt was stopped because the app background transition cancelled observation; the complete repeat passed after that transition finished.
case1-2x.2x.mp4
Controlled case with live event/status evidence.
Reset drains admitted backups before replacing clients ✅ Native retirement waited for held manual and recovery write tails despite reset-caller cancellation. The cancelled recovery callback was not acknowledged; explicit retry wrote the same native empty-wallet envelope and acknowledged once.
Manual 2, native empty-wallet recovery portion. Held post-write completion for SETTINGS and then USDT recovery. Native client retirement waited after the reset caller was cancelled. The cancelled recovery callback left its acknowledgement unchanged; explicit retry with observation stopped acknowledged once. Logs and native readback confirmed identical recovery-envelope bytes and content-addressed keys. The first retirement drained both initialized native client domains; the second had only the app client initialized.
case2-2x.2x.mp4
Controlled case with live event/status evidence.
Wipe rejects new backup work ✅ Manual, full and recovery requests made no upload or status change while wipe admission was closed. Recovery succeeded after reopening, with observation stopped.
Manual 3. With wipe admission closed after fixture reset, manual SETTINGS, full backup and native recovery requests produced no PUT or status change. After reopening, recovery persisted and acknowledged with observation stopped. Checked through event/status logs.
case3-2x.2x.mp4
Controlled case with live event/status evidence.
Full backup retains a newer revision and held status writes ✅ New settings received a successor upload; a consumed status write survived stop/restart; a full request queued behind cancelled cleanup resumed without observation. Native readback contained CHF.
Manual 4. SETTINGS changed during a held full-backup upload and received a successor upload. A consumed required-status write survived stop/restart. A full request made behind cancelled cleanup resumed with observation stopped. Final native decrypted readback contained CHF; the event/status logs show the revision and upload ordering.
case4-2x.2x.mp4
Controlled case with live event/status evidence.
Explicit backup retries while observation is stopped ✅ Socket-drop PUT failures logged 10s then 20s backoff for SETTINGS and native USDT recovery with observation stopped. Each recovered automatically. Background recovery success did not acknowledge Core; its own explicit callback retry did. Recovery payload and content-addressed key stayed identical across both failures and both successful writes.
Manual 5, temporary-failure portion. Closing real VSS PUT connections caused native request-send failures. SETTINGS and native recovery each logged 10-second then 20-second backoff and recovered automatically after transport reopened, with observation stopped. Automatic recovery success did not acknowledge the failed Core callback. Its explicit callback retry did. The recovery payload/key stayed identical across both failed attempts, automatic success and explicit success (logs and native readback).
| Manual SETTINGS retry | Native recovery retry |
|---|---|
case5a-manual-2x.2x.mp4 |
case5a-usdt-2x.2x.mp4 |
Both portions show the real failure, automatic recovery and callback checks.
Cancelled recovery callback leaves its newer revision pending ✅ The queued callback committed a newer required revision before cancellation. Older upload completion synchronized only its own revision, left the newer revision pending and gave no callback acknowledgement. Explicit retry synchronized and acknowledged its own write.
Manual 5, queued-cancellation portion. A newer recovery callback durably reserved its revision while an older upload was held. Cancelling it left that revision pending. The older completion synchronized only its own revision and gave no callback acknowledgement. An explicit retry then synchronized and acknowledged its own write (status/event logs).
case5b-2x.2x.mp4
Controlled case with live event/status evidence.
Reset retains a consumed settings change ✅ A JPY edit was held before its required-status transform. BackupRepo.reset still waited after caller cancellation. Release persisted the pending revision without clearing settings; restarted observation uploaded it, and native decrypted readback returned JPY without another edit.
Manual 4, reset variant. Held a consumed JPY settings change before the required-status transform. BackupRepo.reset() waited even after its caller was cancelled. Release committed the pending revision without clearing wallet data. Restart uploaded it without another edit; native decrypted SETTINGS readback returned JPY.
reset-status-2x.2x.mp4
Controlled case with live event/status evidence.
Reset preserves a signed USDT operation for native retry ✅ A real native signed send was held after its VSS write. Cancelled reset waited for the admitted callback tail; cancellation gave no Core acknowledgement and the local fork recorded zero submissions. Native refresh persisted identical recovery bytes and the same signed-plan digest, acknowledged its own write, then submitted exactly once.
Manual 2, same-signed-operation variant. A real native signed send was held after its VSS write but before callback completion. Reset cancellation waited for that tail; Core received no acknowledgement and the fork recorded zero submissions. Native refresh then backed up identical recovery bytes and the same signed-plan digest, acknowledged its own callback and submitted exactly once. The event log records the digests/acknowledgement; the fixture counters were 0 before reset, 0 after cancellation and 1 after retry. This drives the native wallet and callback, without a Paykit contact-request UI negotiation.
signed-reset-2x.2x.mp4
Controlled case with live event/status evidence.
Check: scheduled backup and retired reservation regressions ✅ The committed BackupRepoTest class passed on the unmodified head.
Ran ./gradlew :app:testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest. The report includes queued Running through debounce, delayed startup cleanup and both reservation-retirement orderings before dispatch. This is component coverage, not a device execution of those exact dispatcher interleavings.
No listed device case is unrun in this run. Reset controls retained wallet data and exercised the backup/VSS drain boundaries; they did not repeat the literal UI wallet-wipe/restore journey. The two-wallet reset/restore report at 1a6048a retains its original scope and recording limits. The unchanged five-minute backoff calculation retains the earlier measured report. The cold-start transport report remains historical evidence for that unchanged classification; this run freshly tested stopped-observer retry admission. No new full-WALLET capture/restore, process-death payment submission or historical HTTP 405 resolution is claimed.
Twin: synonymdev/bitkit-ios#1123 at c75acf3 matches the ownership/drain contract and already marks queued work Running before its delay. Android's admitted-reservation field handles its different immediate-stop/retained-drain design.
Review covered the contiguous 1a6048a..1cf5d39 delta, the scheduler/native-admission/status callers and prior open concerns. The exact pre-dispatch retirement interleavings have component coverage above; the ordinary journey and held lifecycle cases cover the device behavior. This does not resolve the existing complete-WALLET or setup-timeout limitations.
Local test controls and build binding
Not pushed. These controls stage real operations, hold/release their boundaries and log results. Preparation installs a known cache-status baseline; writes and readbacks under test use the actual native clients. The VSS proxy forwards authenticated encrypted requests to staging and can return a controlled conflict or close PUT connections. It never supplies a successful backup result. The signed case uses the repository USDT fixture at ce003b01, with its pinned services, an isolated local fork and test funds.
ARM64 APK SHA-256:
- Unmodified journey build:
7ed2ff08d196a2116b94310dbf767c05d746e35a2224d0f232ec291108f6eb29. - Manual 1–5 controls:
83d4918f43e90c81b306dfdbe61d898336af7c9b7bebccd98476508b85893dfc. - Reset-status and signed-operation controls:
ee8ddae64703f0496913f3d9f5e277af576af85f357be757d44a428407a00fe1.
Both controlled APK hashes were independently read back from the installed package. Base control patch:
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index b88fe73a7..3a33db0c5 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -11,5 +11,8 @@
android:authorities="${applicationId}.devtools"
android:exported="true"
tools:ignore="ExportedContentProvider" />
+ <!-- test hook: disposable review fixture only -->
+ <provider android:name="to.bitkit.dev.BackupFaultProvider" android:authorities="${applicationId}.backupfault" android:exported="true" tools:ignore="ExportedContentProvider" />
+ <activity android:name="to.bitkit.dev.BackupFaultActivity" android:exported="true" />
</application>
</manifest>
diff --git a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
new file mode 100644
index 000000000..a12f981ee
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -0,0 +1,122 @@
+// test hook: shell-only control surface for disposable emulator.
+package to.bitkit.dev
+
+import android.app.Activity
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.widget.TextView
+import android.widget.ScrollView
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.CacheStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.backup.VssBackupClient
+import to.bitkit.models.BackupCategory
+import to.bitkit.models.BackupItemStatus
+import to.bitkit.repositories.BackupRepo
+import to.bitkit.services.UsdtService
+import to.bitkit.testhook.BackupFault
+import java.util.concurrent.ConcurrentHashMap
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface BackupFaultDependencies {
+ fun backup(): BackupRepo
+ fun cache(): CacheStore
+ fun settings(): SettingsStore
+ fun keychain(): Keychain
+ fun vss(): VssBackupClient
+ fun clients(): to.bitkit.data.backup.VssClientCoordinator
+ fun usdt(): UsdtService
+}
+
+class BackupFaultProvider : ContentProvider() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val tasks = ConcurrentHashMap<String, Job>()
+ private val deps by lazy { EntryPointAccessors.fromApplication(requireNotNull(context), BackupFaultDependencies::class.java) }
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ check(Binder.getCallingUid() == 2000)
+ fun asyncTask(name: String, block: suspend () -> Unit) {
+ tasks[name] = scope.launch {
+ BackupFault.event("TASK_BEGIN $name")
+ try { block(); BackupFault.event("TASK_OK $name") }
+ catch (e: Exception) { BackupFault.event("TASK_ERROR $name ${e.javaClass.simpleName}") }
+ }
+ }
+ val answer = when(method) {
+ "init" -> { asyncTask("init") {
+ check(deps.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name) == null)
+ deps.keychain().saveString(Keychain.Key.BIP39_MNEMONIC.name, org.lightningdevkit.ldknode.generateEntropyMnemonic(org.lightningdevkit.ldknode.WordCount.WORDS12))
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "prepare" -> { asyncTask("prepare") {
+ BackupFault.releaseAll(); deps.backup().setWiping(false); deps.backup().reset()
+ deps.settings().update { it.copy(selectedCurrency = "USD") }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "cycle" -> { asyncTask("cycle") { repeat(5) { deps.backup().stopObservingBackups(); deps.backup().startObservingBackups(); delay(500) } }; "started" }
+ "start" -> { deps.backup().startObservingBackups(); BackupFault.event("START_REQUEST"); "ok" }
+ "stop" -> { deps.backup().stopObservingBackups(); BackupFault.event("STOP_REQUEST"); "ok" }
+ "arm" -> { BackupFault.arm(requireNotNull(arg)); "ok" }
+ "release" -> { BackupFault.release(requireNotNull(arg)); "ok" }
+ "currency" -> { asyncTask("currency") { deps.settings().update { it.copy(selectedCurrency = requireNotNull(arg)) } }; "started" }
+ "mark" -> { asyncTask("mark") { deps.cache().updateBackupStatus(BackupCategory.valueOf(requireNotNull(arg))) { it.copy(required = it.required + 1) } }; "started" }
+ "manual" -> { asyncTask("manual") { deps.backup().triggerBackup(BackupCategory.valueOf(requireNotNull(arg))).getOrThrow() }; "started" }
+ "full" -> { deps.backup().scheduleFullBackup(); BackupFault.event("FULL_REQUEST"); "ok" }
+ "usdt" -> { asyncTask(arg ?: "usdt") { deps.usdt().wallet(requiresNetwork = false); val snapshot = requireNotNull(deps.usdt().backupSnapshot()); BackupFault.event("RECOVERY_SNAPSHOT ${BackupFault.digest(snapshot.encodeToByteArray())}"); deps.usdt().recoveryBackup.persist(snapshot) }; "started" }
+ "reset" -> { asyncTask("reset") { deps.clients().withSuspendedClients(shutdown = { domain ->
+ BackupFault.event("SHUTDOWN $domain")
+ when (domain) {
+ to.bitkit.data.backup.VssClientCoordinator.Domain.APP -> com.synonym.vssclient.vssShutdownClient()
+ to.bitkit.data.backup.VssClientCoordinator.Domain.LDK -> com.synonym.vssclient.vssShutdownLdkClient()
+ }
+ }) { deps.backup().reset() } }; "started" }
+ "cancel" -> { tasks[arg]?.cancel(); BackupFault.event("CANCEL $arg"); "ok" }
+ "wipe" -> { deps.backup().setWiping(arg == "true"); BackupFault.event("WIPE $arg"); "ok" }
+ "clear" -> { BackupFault.events.clear(); "ok" }
+ "fetch" -> { asyncTask("fetch") { val item = deps.vss().getObject(requireNotNull(arg)).getOrThrow(); BackupFault.event("FETCH $arg present=${item != null} sha256=${item?.value?.let(BackupFault::digest)}" + if (arg == "SETTINGS" && item != null) " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(item.value.decodeToString()).settings.selectedCurrency else "") }; "started" }
+ "status" -> runBlocking(Dispatchers.IO) {
+ "ack=" + deps.usdt().recoveryGeneration + " currency=" + deps.settings().data.first().selectedCurrency + " statuses=" + deps.cache().backupStatuses.first().filterKeys { it.name == (arg ?: "SETTINGS") }.entries.joinToString { "${it.key}=${it.value}" }
+ }
+ else -> BackupFault.events.joinToString("\n")
+ }
+ if (method == "status") BackupFault.event(answer)
+ return Bundle().apply { putString("result", answer) }
+ }
+ override fun onCreate() = true
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
+ override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
+ override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
+}
+
+class BackupFaultActivity : Activity() {
+ private val handler = Handler(Looper.getMainLooper())
+ private lateinit var text: TextView
+ private val refresh = object : Runnable {
+ override fun run() {
+ val output = "Backup lifecycle fault fixture\n1cf5d39 + local hold/log hooks\nReal DataStore and native encrypted VSS\nUnfunded staging wallet; no payments\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ if (text.text.toString() != output) text.text = output // test hook
+ handler.postDelayed(this, 250)
+ }
+ }
+ override fun onCreate(saved: Bundle?) {
+ super.onCreate(saved)
+ text = TextView(this).apply { setTextColor(Color.WHITE); setBackgroundColor(Color.BLACK); textSize = 14f; setPadding(24,60,24,20) }
+ setContentView(ScrollView(this).apply { addView(text) }); handler.post(refresh)
+ }
+ override fun onDestroy() { handler.removeCallbacks(refresh); super.onDestroy() }
+}
diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt
index 1c55b1894..9fec449f8 100644
--- a/app/src/main/java/to/bitkit/data/CacheStore.kt
+++ b/app/src/main/java/to/bitkit/data/CacheStore.kt
@@ -38,7 +38,9 @@ class CacheStore internal constructor(
val backupStatuses: Flow<Map<BackupCategory, BackupItemStatus>> = data.map { it.backupStatuses }
suspend fun update(transform: (AppCacheData) -> AppCacheData) {
+ to.bitkit.testhook.BackupFault.hold("cache") // test hook
store.updateData(transform)
+ to.bitkit.testhook.BackupFault.hold("cacheAfter", drain = true) // test hook
}
suspend fun setOnchainAddress(address: String) {
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
index 728b112e4..254a538bd 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
@@ -35,6 +35,8 @@ class VssBackupClient @Inject constructor(
private val keychain: Keychain,
) {
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.event("SETUP_ENTER") // test hook
+ to.bitkit.testhook.BackupFault.hold("setup", drain = true) // test hook
runCatching {
coordinator.setup(VssClientCoordinator.Domain.APP) {
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
@@ -109,6 +111,7 @@ class VssBackupClient @Inject constructor(
key: String,
data: ByteArray,
): Result<VssItem> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.beforePut(key, data) // test hook
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
@@ -118,8 +121,11 @@ class VssBackupClient @Inject constructor(
)
}
}.onSuccess {
+ to.bitkit.testhook.BackupFault.event("PUT_OK $key sha256=${to.bitkit.testhook.BackupFault.digest(data)}") // test hook
+ to.bitkit.testhook.BackupFault.hold("post:${to.bitkit.testhook.BackupFault.gateKey(key)}", drain = true) // test hook
Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG)
}.onFailure {
+ to.bitkit.testhook.BackupFault.event("PUT_ERROR $key ${it.javaClass.simpleName}") // test hook
Logger.verbose("VSS 'putObject' error for '$key'", it, context = TAG)
}
}
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
index 83a1ed564..299bcc477 100644
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -101,7 +101,7 @@ internal object Env {
)
val vssServerUrl
- get() = backupTestEndpoints?.vss ?: when (network) {
+ get() = if (BuildConfig.DEBUG) "http://127.0.0.1:24889" else backupTestEndpoints?.vss ?: when (network) { // test hook
Network.BITCOIN -> "https://bitkit.to/vss_rs_auth"
else -> "https://bitkit.stag0.blocktank.to/vss_rs_auth"
}
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index 94c3544ce..4b2071918 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -623,6 +623,7 @@ class BackupRepo @Inject constructor(
updateBackupStatus(category, expectedGeneration) { it.copy(running = true) }
owner?.ready?.await()
// Coalescing pending work must not delay an explicit recovery acknowledgement.
+ to.bitkit.testhook.BackupFault.event("DEBOUNCE $category") // test hook
delay(BACKUP_DEBOUNCE)
categoryMutexes.getValue(category).withLock {
runningBackups += category
@@ -991,6 +992,7 @@ class BackupRepo @Inject constructor(
temporaryFailureSince.putIfAbsent(category, backupRequired)
val retryDelay = (TEMPORARY_FAILURE_RETRY_DELAY * (1 shl (attempt - 1).coerceAtMost(MAX_RETRY_SHIFT)))
.coerceAtMost(TEMPORARY_FAILURE_RETRY_MAX_DELAY)
+ to.bitkit.testhook.BackupFault.event("RETRY $category delay=$retryDelay revision=$backupRequired") // test hook
Logger.warn("Retrying backup for: '$category' in '$retryDelay'", e, context = TAG)
pendingScope.launch(start = CoroutineStart.LAZY) {
delay(retryDelay)
diff --git a/app/src/main/java/to/bitkit/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt
index ef299b029..72236e4c9 100644
--- a/app/src/main/java/to/bitkit/services/UsdtService.kt
+++ b/app/src/main/java/to/bitkit/services/UsdtService.kt
@@ -38,6 +38,7 @@ class UsdtService @Inject constructor(
internal val recoveryBackup = object : UsdtBackup {
override suspend fun persist(snapshot: String) {
+ to.bitkit.testhook.BackupFault.event("CALLBACK_ENTER sha256=${to.bitkit.testhook.BackupFault.digest(snapshot.encodeToByteArray())}") // test hook
backupRepo.get().persistUsdtRecoveryBackup(snapshot).getOrElse {
Logger.error(
"Failed to persist USDT recovery backup with '${it.javaClass.simpleName}'",
@@ -46,6 +47,7 @@ class UsdtService @Inject constructor(
throw UsdtException.BackupUnavailable()
}
backupAcknowledgements.incrementAndGet()
+ to.bitkit.testhook.BackupFault.event("CALLBACK_ACK sha256=${to.bitkit.testhook.BackupFault.digest(snapshot.encodeToByteArray())}") // test hook
}
}
diff --git a/app/src/main/java/to/bitkit/testhook/BackupFault.kt b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
new file mode 100644
index 000000000..f99eb5b6c
--- /dev/null
+++ b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
@@ -0,0 +1,40 @@
+// test hook: local review fixture, never shipped.
+package to.bitkit.testhook
+
+import android.util.Log
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import to.bitkit.BuildConfig
+
+object BackupFault {
+ private val gates = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
+ val events = CopyOnWriteArrayList<String>()
+ fun event(text: String) {
+ if (!BuildConfig.DEBUG) return
+ val line = "${android.os.SystemClock.elapsedRealtime()} $text"
+ events.add(line)
+ Log.i("BackupFault", line)
+ }
+ fun arm(name: String) { gates[name] = CompletableDeferred(); event("ARM $name") }
+ fun release(name: String) { gates.remove(name)?.complete(Unit); event("RELEASE $name") }
+ fun releaseAll() { gates.keys.toList().forEach { release(it) } }
+ suspend fun hold(name: String, drain: Boolean = false) {
+ if (!BuildConfig.DEBUG) return
+ val gate = gates[name] ?: return
+ event("HELD $name")
+ if (drain) withContext(NonCancellable) { gate.await() } else gate.await()
+ gates.remove(name, gate)
+ event("EXIT $name")
+ }
+ fun gateKey(key: String) = if (key.startsWith("USDT_RECOVERY_")) "USDT_RECOVERY" else key
+ suspend fun beforePut(key: String, data: ByteArray) {
+ event("PUT_ENTER $key sha256=${digest(data)}" + if (key == "SETTINGS") " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(data.decodeToString()).settings.selectedCurrency else "")
+ try { hold("put:${gateKey(key)}") }
+ finally { hold("cleanup:${gateKey(key)}", drain = true) }
+ }
+ fun digest(data: ByteArray) = MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) }
+}
Additional controls/configuration for reset-status and the signed operation:
diff --git a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
--- a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -44,6 +44,7 @@
class BackupFaultProvider : ContentProvider() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val tasks = ConcurrentHashMap<String, Job>()
+ private var quote: com.synonym.bitkitcore.UsdtQuote? = null // test hook
private val deps by lazy { EntryPointAccessors.fromApplication(requireNotNull(context), BackupFaultDependencies::class.java) }
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == 2000)
@@ -76,6 +77,18 @@
"manual" -> { asyncTask("manual") { deps.backup().triggerBackup(BackupCategory.valueOf(requireNotNull(arg))).getOrThrow() }; "started" }
"full" -> { deps.backup().scheduleFullBackup(); BackupFault.event("FULL_REQUEST"); "ok" }
"usdt" -> { asyncTask(arg ?: "usdt") { deps.usdt().wallet(requiresNetwork = false); val snapshot = requireNotNull(deps.usdt().backupSnapshot()); BackupFault.event("RECOVERY_SNAPSHOT ${BackupFault.digest(snapshot.encodeToByteArray())}"); deps.usdt().recoveryBackup.persist(snapshot) }; "started" }
+ "address" -> { val address = deps.usdt().wallet(requiresNetwork = false).receiveAddress(); BackupFault.event("ADDRESS $address"); address } // test hook
+ "quote" -> { asyncTask("quote") { quote = deps.usdt().wallet().quoteTransfer(requireNotNull(arg), 1_000_000uL, com.synonym.bitkitcore.UsdtDestination.ARBITRUM); BackupFault.event("QUOTE ${quote?.id}") }; "started" } // test hook
+ "send" -> { asyncTask(arg ?: "send") { val transfer = deps.usdt().send(requireNotNull(quote)); BackupFault.event("SEND_RESULT ${transfer.id} ${transfer.status}") }; "started" } // test hook
+ "scheduledSend" -> { asyncTask("scheduledSend") { // test hook
+ deps.cache().updateBackupStatus(BackupCategory.USDT_RECOVERY) { it.copy(required = it.required + 1) } // test hook
+ while (BackupFault.events.none { it.contains("DEBOUNCE USDT_RECOVERY") }) delay(1) // test hook
+ val transfer = deps.usdt().send(requireNotNull(quote)); BackupFault.event("SEND_RESULT ${transfer.id} ${transfer.status}") // test hook
+ }; "started" } // test hook
+ "refresh" -> { asyncTask("refresh") { deps.usdt().wallet().refreshTransfers(); BackupFault.event("NATIVE_REFRESH_DONE") }; "started" } // test hook
+ "snapshot" -> { val root = org.json.JSONObject(requireNotNull(deps.usdt().backupSnapshot())); val values = root.getJSONArray("transfers"); BackupFault.event("SNAPSHOT transfers=${values.length()}"); for (index in 0 until values.length()) { val entry = values.getJSONObject(index); val plan = entry.optJSONObject("plan"); if (plan != null) BackupFault.event("PLAN_DIGEST ${BackupFault.digest(plan.toString().encodeToByteArray())}") }; "ok" } // test hook
+ "history" -> { val history = deps.usdt().wallet(requiresNetwork = false).history(); val text = history.joinToString { "${it.id}:${it.status}" }; BackupFault.event("HISTORY $text"); text } // test hook
+ "backupReset" -> { asyncTask("backupReset") { deps.backup().reset() }; "started" } // test hook
"reset" -> { asyncTask("reset") { deps.clients().withSuspendedClients(shutdown = { domain ->
BackupFault.event("SHUTDOWN $domain")
when (domain) {
@@ -108,7 +121,7 @@
private lateinit var text: TextView
private val refresh = object : Runnable {
override fun run() {
- val output = "Backup lifecycle fault fixture\n1cf5d39 + local hold/log hooks\nReal DataStore and native encrypted VSS\nUnfunded staging wallet; no payments\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ val output = "Backup lifecycle fault fixture\n1cf5d39 + local hold/log hooks\nReal DataStore and native encrypted VSS\nDisposable test wallet; no live funds\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
if (text.text.toString() != output) text.text = output // test hook
handler.postDelayed(this, 250)
}
diff --git a/local.properties b/local.properties
new file mode 100644
--- /dev/null
+++ b/local.properties
@@ -0,0 +1,5 @@
+USDT_RPC_URL=http://127.0.0.1:23453/v1/usdt/chain-rpc
+USDT_BUNDLER_URL=http://127.0.0.1:23453/v1/usdt/rpc
+USDT_DEPOSITS_URL=http://127.0.0.1:23453/v1/usdt/deposits
+USDT_BRIDGES_URL=http://127.0.0.1:23453/v1/usdt/bridges
+USDT_BRIDGE_NETWORKS=ethereum,polygon,plasma,stable
Fault proxy:
from http.server import ThreadingHTTPServer,BaseHTTPRequestHandler
from urllib.request import Request,urlopen
from urllib.error import HTTPError
from pathlib import Path
import threading,time
lock=threading.Lock()
state={'fail':0,'drop':False,'permanent':False}
class Handler(BaseHTTPRequestHandler):
def log_message(self,*args): pass
def do_GET(self): self.relay()
def do_POST(self): self.relay()
def relay(self):
body=self.rfile.read(int(self.headers.get('Content-Length','0')))
if self.path=='/control/fail':
with lock: state['fail']=1000; state['drop']=False
self.send_response(200); self.end_headers(); self.wfile.write(b'armed'); return
if self.path=='/control/drop':
with lock: state['drop']=True; state['fail']=0
self.send_response(200); self.end_headers(); self.wfile.write(b'drop armed'); return
if self.path=='/control/permanent':
with lock: state['permanent']=True; state['drop']=False; state['fail']=0
self.send_response(200); self.end_headers(); self.wfile.write(b'permanent armed'); return
if self.path=='/control/healthy':
with lock: state['fail']=0; state['drop']=False; state['permanent']=False
self.send_response(200); self.end_headers(); self.wfile.write(b'healthy'); return
with lock:
fail=state['fail']>0 and self.command=='POST' and 'put' in self.path.lower()
if fail: state['fail']-=1
if state['permanent'] and self.command=='POST' and 'put' in self.path.lower():
self.send_response(400); self.send_header('Content-Type','application/octet-stream'); self.end_headers(); self.wfile.write(b'\x08\x02\x12\x17controlled invalid data');print(time.time(),'FAULT_PERMANENT',self.command,self.path,flush=True); return
if state['drop'] and self.command=='POST' and 'put' in self.path.lower():
self.close_connection=True; print(time.time(),'FAULT_DROP',self.command,self.path,flush=True); return
if fail:
self.send_response(409); self.send_header('Content-Type','application/octet-stream'); self.end_headers(); self.wfile.write(b'\x08\x01\x12\x13controlled conflict');print(time.time(),'FAULT_409',self.command,self.path,flush=True); return
headers={k:v for k,v in self.headers.items() if k.lower() not in ('host','connection','accept-encoding')}
req=Request('https://bitkit.stag0.blocktank.to/vss_rs_auth'+self.path,data=body if self.command=='POST' else None,headers=headers,method=self.command)
try: result=urlopen(req,timeout=90)
except HTTPError as e: result=e
except Exception as e:
self.send_response(502);self.end_headers();self.wfile.write(type(e).__name__.encode());print('PROXY_ERROR',type(e).__name__,flush=True);return
data=result.read();self.send_response(result.status)
for k,v in result.headers.items():
if k.lower() not in ('connection','transfer-encoding','content-length','content-encoding'): self.send_header(k,v)
self.send_header('Content-Length',str(len(data))); self.end_headers(); self.wfile.write(data)
print(time.time(),self.command,self.path.split('?')[0],result.status,flush=True)
server=ThreadingHTTPServer(('127.0.0.1',24889),Handler)
threading.Timer(10800,server.shutdown).start()
server.serve_forever()Deciding event excerpts (elapsed milliseconds):
case1
625803 START_REQUEST
643871 PUT_ERROR BLOCKTANK StoreException
643893 RETRY BLOCKTANK delay=10s revision=1793820532473
646120 HELD cleanup:WIDGETS
649346 START_REQUEST
669422 ack=0 currency=EUR statuses=SETTINGS=BackupItemStatus(running=false, synced=1000, required=1793820541835)
670973 ack=0 currency=EUR statuses=SETTINGS=BackupItemStatus(running=false, synced=1000, required=1793820541835)
672017 RELEASE cleanup:WIDGETS
677095 PUT_ENTER SETTINGS sha256=f70b25cd18b6a3535e7b9f96edfc6f0729051f54e7a6c3832076bb65c69fcc4b currency=EUR
677371 PUT_OK SETTINGS sha256=f70b25cd18b6a3535e7b9f96edfc6f0729051f54e7a6c3832076bb65c69fcc4b
682868 ack=0 currency=EUR statuses=SETTINGS=BackupItemStatus(running=false, synced=1793820566483, required=1793820566483)
684309 FETCH SETTINGS present=true sha256=f70b25cd18b6a3535e7b9f96edfc6f0729051f54e7a6c3832076bb65c69fcc4b currency=EUR
688311 RELEASE cleanup:WIDGETS
689359 ack=0 currency=EUR statuses=SETTINGS=BackupItemStatus(running=false, synced=1793820566483, required=1793820566483)
case2
716876 HELD post:SETTINGS
721920 CANCEL reset
726379 SHUTDOWN APP
726380 SHUTDOWN LDK
731989 HELD post:USDT_RECOVERY
737286 CANCEL reset
742545 SHUTDOWN APP
742546 TASK_ERROR usdt BackupUnavailable
777694 CALLBACK_ACK sha256=3a2a6ab0a1a87a6f6fed3ed441f162b127ee460598fdfc41ea3fb9d376c1977f
case3
806317 WIPE true
812835 TASK_ERROR manual BackupUnavailable
815655 TASK_ERROR usdt BackupUnavailable
832127 WIPE false
838561 PUT_OK USDT_RECOVERY_V1_dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c sha256=dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c
838617 CALLBACK_ACK sha256=3a2a6ab0a1a87a6f6fed3ed441f162b127ee460598fdfc41ea3fb9d376c1977f
842914 WIPE false
case4
873687 FULL_REQUEST
894872 HELD cache
894882 HELD cache
896852 STOP_REQUEST
898422 HELD cache
920596 PUT_ENTER SETTINGS sha256=991843088d8eab9184bb4e984dfcce3d2677df014383212ebef3e1adbf9d295a currency=CHF
922539 STOP_REQUEST
925754 FULL_REQUEST
930812 PUT_ENTER SETTINGS sha256=8c06cf45924159a230d27cd9c5870a14f5be2f49e6228d2173708dfcd09f07d9 currency=CHF
934149 ack=2 currency=CHF statuses=SETTINGS=BackupItemStatus(running=false, synced=1793820820222, required=1793820820222)
935386 FETCH SETTINGS present=true sha256=8c06cf45924159a230d27cd9c5870a14f5be2f49e6228d2173708dfcd09f07d9 currency=CHF
941228 ack=2 currency=CHF statuses=SETTINGS=BackupItemStatus(running=false, synced=1793820820222, required=1793820820222)
case5a-manual
990465 PUT_ERROR SETTINGS UnknownException
990846 RETRY SETTINGS delay=10s revision=1793820868181
1016671 PUT_ERROR SETTINGS UnknownException
1016683 RETRY SETTINGS delay=20s revision=1793820895564
1042010 PUT_OK SETTINGS sha256=971bd5c53e67f3b97435de3e7d814375fe5ec4a9c3ddf470be91d481850037a2
case5a-usdt
1067028 PUT_ERROR USDT_RECOVERY_V1_dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c UnknownException
1067038 RETRY USDT_RECOVERY delay=10s revision=1793820946093
1070719 ack=2 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1793820946093)
1092526 PUT_ERROR USDT_RECOVERY_V1_dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c UnknownException
1092563 RETRY USDT_RECOVERY delay=20s revision=1793820971525
1118022 PUT_OK USDT_RECOVERY_V1_dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c sha256=dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c
1119871 ack=2 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793821007027, required=1793821007027)
1120893 ack=2 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793821007027, required=1793821007027)
1123159 PUT_OK USDT_RECOVERY_V1_dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c sha256=dc89f2d7ebc15e026dbf59104628ee979c24fde182d0f3fa56f200d1fcb6a45c
1123170 CALLBACK_ACK sha256=3a2a6ab0a1a87a6f6fed3ed441f162b127ee460598fdfc41ea3fb9d376c1977f
1124924 ack=3 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793821012390, required=1793821012390)
1128955 ack=3 currency=USD statuses=SETTINGS=BackupItemStatus(running=false, synced=1793820931176, required=1793820931176)
case5b
1160402 ack=3 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1000)
1166177 ack=3 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=true, synced=1000, required=1793821053524)
1170056 ack=3 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=true, synced=1000, required=1793821056631)
1171068 CANCEL queued
1175059 ack=3 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=true, synced=1793821053524, required=1793821056631)
1176089 ack=3 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=true, synced=1793821053524, required=1793821056631)
1178447 CALLBACK_ACK sha256=3a2a6ab0a1a87a6f6fed3ed441f162b127ee460598fdfc41ea3fb9d376c1977f
1180285 ack=4 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793821067657, required=1793821067657)
1181359 ack=4 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793821067657, required=1793821067657)
1183460 ack=4 currency=USD statuses=SETTINGS=BackupItemStatus(running=false, synced=1000, required=1000)
reset-status
1273898 HELD cache
1279031 CANCEL backupReset
1284158 TASK_ERROR backupReset JobCancellationException
1286290 ack=0 currency=JPY statuses=SETTINGS=BackupItemStatus(running=false, synced=1000, required=1793821173573)
1287343 ack=0 currency=JPY statuses=SETTINGS=BackupItemStatus(running=false, synced=1000, required=1793821173573)
1293369 PUT_ENTER SETTINGS sha256=b38de9f61ee985bf140d9291b2ab2bccad64606a20c8444716f648f880dc3fe9 currency=JPY
1297245 ack=0 currency=JPY statuses=SETTINGS=BackupItemStatus(running=false, synced=1793821182775, required=1793821182775)
1298429 FETCH SETTINGS present=true sha256=b38de9f61ee985bf140d9291b2ab2bccad64606a20c8444716f648f880dc3fe9 currency=JPY
1302448 ack=0 currency=JPY statuses=SETTINGS=BackupItemStatus(running=false, synced=1793821182775, required=1793821182775)
signed-reset
1336523 CALLBACK_ENTER sha256=07ce957fe8f499ba12de1771d62ca74d552a6e2ab80bfae2075245c7abfd65cb
1336735 HELD post:USDT_RECOVERY
1338161 PLAN_DIGEST 624dc448c4fb8061a4351db13ae8abbc4f47e25061a0ff627ec0dab7e9a97bd8
1345529 CANCEL reset
1348659 SHUTDOWN APP
1348659 SHUTDOWN LDK
1348662 TASK_ERROR signed-send BackupUnavailable
1352842 PLAN_DIGEST 624dc448c4fb8061a4351db13ae8abbc4f47e25061a0ff627ec0dab7e9a97bd8
1356844 CALLBACK_ENTER sha256=07ce957fe8f499ba12de1771d62ca74d552a6e2ab80bfae2075245c7abfd65cb
1357772 CALLBACK_ACK sha256=07ce957fe8f499ba12de1771d62ca74d552a6e2ab80bfae2075245c7abfd65cb
1359245 NATIVE_REFRESH_DONE
1362248 PLAN_DIGEST 624dc448c4fb8061a4351db13ae8abbc4f47e25061a0ff627ec0dab7e9a97bd8
Reviewed by gpt-6.1-sol-high
jvsena42
left a comment
There was a problem hiding this comment.
Delta review: be4e1ba plus a base merge. No findings. ensureSetupForUpload moves the existing setup-timeout handling into one helper, and now also applies it to the wallet and USDT recovery uploads. So a VSS setup timeout there throws BackupClientSetupTimeout and is retried, instead of surfacing as a cancellation. Cancellation is still rethrown through ensureActive.
Device gate: unchanged from my earlier approval; this change is in the upload error path only.
Reviewer model: Claude Opus 5.5 (claude-opus-5-5)
Reasoning effort: low
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of bitkit-android#1689 at be4e1ba, because the comparison base moved from a2c58f2 to b5d5858d and no verified baseline coverage was supplied. The merge base is b5d5858db35bce4ecd82acc02e9765bf5e63edbe.
Code review completed with unverified earlier concerns.
Explicit backups keep the pending revision when VSS client setup reaches its own deadline or another temporary upload failure occurs, including while Lightning observation is stopped. Reset and caller cancellation drain that setup before an upload. Wallet and USDT recovery use that same temporary-failure path. Profile deletion calls deleteProfile once; an uncertain homeserver response shows the failure dialog and does not sign in again.
Wallet backup still fails closed when a Pubky identity exists without Paykit state. That check is unchanged from the parent.
bitkit-ios#1123 at 61537ebd follows the same backup observation and explicit-retry direction. Its profile deletion path still refreshes the session and deletes again, and that PR does not carry journeys/profile/delete-profile-uncertain-response.xml. Only VSS client admission was checked against parent bitkit-android#1686 at b5d5858d.
Validation: BackupRepoTest, PubkyRepoTest, and EditProfileViewModelTest were inspected and not executed here because the Android SDK is not available. On this head, build-and-test and Compose UI passed and lint passed. E2E failed lightning_security, multi_address_local, and onchain_boost_receive_widgets while waiting for SendSuccess or a balance string. Wallet backup waits until payment submission is idle before uploading, so those waits were not traced to this diff. Device testing: not performed in this review.
Suggested additional test cases
- Android, disposable profile, proxy that accepts one profile deletion and drops the acknowledgement. Confirm deletion, wait out the network timeout, dismiss the failure dialog, and restart without Retry. The failure dialog is shown, onboarding is not, and there is no second profile deletion or sign-in.
- Android, disposable wallet, proxy that stalls VSS client setup until its 30-second deadline and then allows it. With observation stopped, edit settings or request a USDT recovery backup. The first attempt stays pending, and the later retry uploads that pending category once without another edit.
Earlier findings reconciled at this revision: 1 still present, 6 verified fixed, 1 could not be verified, 1 tracked separately outside this PR.
Tracked separately (outside this PR; unresolved): Native LDK shutdown abort during VSS outage. The reported VSS-outage native shutdown abort remains the open ldk-node shutdown failure in #1536. From base b5d5858 to head be4e1ba this PR does not change LightningService.stop, the ldk-node-android 0.7.0-rc.78 pin, or native LDK sources. The issue stays open. This stage performed no native execution and does not treat the issue as a fix or a current-head pass. This is not a runtime verification or merge-readiness declaration.
Reconciliation incomplete: the following earlier concerns remain unverified. This report does not establish that they are fixed or invalid, or establish approval or device readiness.
Could not verify earlier concern — Signed-in outage recovery encountered Pubky lock/HTTP 405 errors: The HTTP 405 Pubky write-lock failure is an attributed device report at 9cfe052. A later attributed signed-in repeat at a3693b0 completed WALLET, METADATA, and SETTINGS and said that repeat does not explain the earlier HTTP 405 or identify a code fix. Current source still propagates Paykit snapshot failures and still refuses metadata replacement while a required WALLET backup has quarantined public addresses. Paykit Transport remains temporary, and the AuthException classifier matches only a VSS request-send string, so neither path shows a stuck HTTP 405. No current reproduction, native trace, or exact tracking issue was available in this code-only pass, so recurrence and resolution stay unverified.
Existing concerns
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — still unresolved; already tracked.
Reviewer model: grok-4.7
Reasoning effort: xhigh
There was a problem hiding this comment.
Verdict: 💬 Comment — setup-timeout recovery passed; the existing complete-WALLET availability concern remains separate.
Findings: No new findings in this delta. WALLET and USDT uploads now enter temporary-failure bookkeeping when setup reaches its own deadline, while caller cancellation and wallet retirement still stop the original operation. The previously reported complete-WALLET availability concern remains unresolved.
QA for be4e1ba: Android 15 / API 35 ARM64 emulator, built from this head. The four backup cases use the disclosed local Debug holds and event display, real DataStore, and released native VSS authentication/encryption against staging. The additional profile case uses the actual Edit Profile screen with a local Pubky testnet and response-holding proxy. Case claims were checked against the recorded event display and device logs; payload comparisons use native decrypted VSS readback.
Wallet backup retries after setup times out ✅ The real 30-second setup deadline left WALLET pending and idle. With observation stopped, the next setup began 15.045 seconds after failure; the upload synchronized the revision and native decrypted readback matched.
Held initialization inside its existing deadline, then allowed normal setup, capture and upload. This uses a disposable wallet without a Pubky identity; it does not prove terminal-identity complete-WALLET availability.
wallet-timeout-2x.2x.mp4
Controlled case with live event/status evidence.
Recovery backup retries after setup times out ✅ Setup timeout withheld Core acknowledgement. Automatic retry began after 15.105 seconds, wrote the original native recovery snapshot and synchronized the revision. Native readback matched; explicit callback retry wrote the identical immutable record and acknowledged once.
Observation remained stopped. The recovery snapshot came from the released native wallet. Background success left the acknowledgement counter unchanged; only the later explicit callback advanced it. This is an empty-wallet recovery envelope, without a signed payment or broadcast.
usdt-timeout-2x.2x.mp4
Controlled case with live event/status evidence.
Cancelling wallet setup stops the original backup ✅ Caller cancellation during held setup propagated as cancellation, left WALLET pending and idle, and produced no upload or automatic retry during the subsequent 17-second observation. A later explicit request succeeded.
The cancellable hold sits inside setup. It was released after the caller had cancelled; no timeout was injected in this case.
wallet-cancel-2x.2x.mp4
Controlled case with live event/status evidence.
Wallet retirement stops held recovery setup ✅ Retiring the VSS owner cancelled held recovery setup without a write, retry or Core acknowledgement during the subsequent 17-second observation. An explicit callback under the new owner then synchronized and acknowledged once.
The coordinator retired its native client generation and reset the backup repository while retaining the disposable wallet data. The signed-operation reset case retains its separate earlier evidence; this case does not replace the recovery phrase or perform a UI wallet wipe.
usdt-retire-2x.2x.mp4
Controlled case with live event/status evidence.
Uncertain profile deletion does not repeat ✅ A real profile DELETE returned 204 to the proxy, whose response was withheld until the native timeout. Edit Profile showed failure options. Dismissal and process restart produced no second app deletion, cleanup, app-level sign-in call or success effect; the backend profile remained absent.
Drove journeys/profile/delete-profile-uncertain-response.xml, suggested in the latest QA review. The actual Edit Profile screen and ViewModel ran in a local Debug activity, using the released SDK and a disposable Pubky 0.15 testnet profile. The fixture forwarded the deletion and held its successful response. The failure dialog showed Retry and Disconnect; Back dismissed it without choosing either. After a 17-second observation, the app process was stopped, its original session restored through the repository initialization path, and the screen reopened for another 17 seconds.
Repository entry counters recorded one cleanup and one deletion, zero app-level sign-in calls and no success/exit effect. The proxy recorded exactly one accepted profile deletion for this identity and no later profile write; a separate backend read returned 404. Native grant-session restoration requests are recorded separately from the app-level sign-in counter. This covers the controlled screen/session-recovery path, rather than the full MainActivity navigation host.
profile-delete-repeat-2x.2x.mp4
Actual Edit Profile failure and restart, with proxy and repository counters checked.
Check: backup timeout and cancellation regressions ✅ The unmodified head built and the focused BackupRepoTest class passed.
Ran ./gradlew assembleDevDebug :app:testDevDebugUnitTest --tests to.bitkit.repositories.BackupRepoTest, including the new WALLET and USDT setup-timeout controls and existing ordinary-category timeout, cancellation and retirement coverage. This is component coverage; the four backup cases above exercise the installed controlled build.
No planned device case remains unrun. Manual 1–5, queued Running status and the signed-operation reset variant retain the recorded results at 1cf5d39; they are not new executions in this run. Manual 6–8 retain the cold-start/read-refusal evidence in the PR description and the authentication transport report for unchanged paths. This run adds no literal wallet wipe/restore, complete-WALLET recovery, process-death payment replay or chain-finality claim.
Twin: synonymdev/bitkit-ios#1123 at 61537ebd retains the matching ownership/drain and immutable recovery contracts, but its generic setup timeout is not classified as temporary. It does not yet match this Android timeout-retry repair. No iOS device execution is claimed here.
Review covered the contiguous 1cf5d39..be4e1ba delta, the shared helper and upload/status/retry callers, inherited backup/restore composition and prior open concerns. Unrelated imported parent features were not driven by these device cases.
Local test controls, build binding and deciding logs
Not pushed. The timeout control suspends inside the existing setup deadline until its real 30-second timeout fires. The cancellation control waits on an explicitly released gate at that same boundary. Other controls invoke repository operations and log actual upload/callback/status results. They do not supply a successful SDK result or change retry, cancellation, snapshot or acknowledgement decisions. The backup cases use no proxy, custom VSS response or payment fixture. The profile case uses the repository marketplace fixture at ce003b01, its existing Pubky TLS proxy, a Pubky 0.15 image and the response-holding relay below.
ARM64 APK SHA-256:
- Unmodified head:
d818874834352e58e7f7c2558acec294b15b8360afd40a9fc30bb0774dc5f3d7. - Controlled build:
501f674023befae0a89546bb3b83e4817c580cf3a84032798271355ee16f15a2; the installed package independently matched this digest.
Full control patch:
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index b88fe73a7..3a33db0c5 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -11,5 +11,8 @@
android:authorities="${applicationId}.devtools"
android:exported="true"
tools:ignore="ExportedContentProvider" />
+ <!-- test hook: disposable review fixture only -->
+ <provider android:name="to.bitkit.dev.BackupFaultProvider" android:authorities="${applicationId}.backupfault" android:exported="true" tools:ignore="ExportedContentProvider" />
+ <activity android:name="to.bitkit.dev.BackupFaultActivity" android:exported="true" />
</application>
</manifest>
diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt
index 1c55b1894..9fec449f8 100644
--- a/app/src/main/java/to/bitkit/data/CacheStore.kt
+++ b/app/src/main/java/to/bitkit/data/CacheStore.kt
@@ -38,7 +38,9 @@ class CacheStore internal constructor(
val backupStatuses: Flow<Map<BackupCategory, BackupItemStatus>> = data.map { it.backupStatuses }
suspend fun update(transform: (AppCacheData) -> AppCacheData) {
+ to.bitkit.testhook.BackupFault.hold("cache") // test hook
store.updateData(transform)
+ to.bitkit.testhook.BackupFault.hold("cacheAfter", drain = true) // test hook
}
suspend fun setOnchainAddress(address: String) {
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
index aaa167768..75df1a3a4 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
@@ -47,12 +47,15 @@ class VssBackupClient @Inject constructor(
private val keychain: Keychain,
) {
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.event("SETUP_ENTER") // test hook
+ to.bitkit.testhook.BackupFault.hold("setup", drain = true) // test hook
runCatching {
coordinator.setup(VssClientCoordinator.Domain.APP) {
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw MnemonicNotAvailableException()
withTimeout(30.seconds) {
+ to.bitkit.testhook.BackupFault.setupBoundary() // test hook
Logger.debug("VSS client setting up…", context = TAG)
val vssUrl = Env.vssServerUrl
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
@@ -121,6 +124,7 @@ class VssBackupClient @Inject constructor(
key: String,
data: ByteArray,
): Result<VssItem> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.beforePut(key, data) // test hook
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
@@ -130,8 +134,11 @@ class VssBackupClient @Inject constructor(
)
}
}.onSuccess {
+ to.bitkit.testhook.BackupFault.event("PUT_OK $key sha256=${to.bitkit.testhook.BackupFault.digest(data)}") // test hook
+ to.bitkit.testhook.BackupFault.hold("post:${to.bitkit.testhook.BackupFault.gateKey(key)}", drain = true) // test hook
Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG)
}.onFailure {
+ to.bitkit.testhook.BackupFault.event("PUT_ERROR $key ${it.javaClass.simpleName}") // test hook
Logger.verbose("VSS 'putObject' error for '$key'", it, context = TAG)
}
}
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index f51579f8c..4cd325846 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -624,6 +624,7 @@ class BackupRepo @Inject constructor(
updateBackupStatus(category, expectedGeneration) { it.copy(running = true) }
owner?.ready?.await()
// Coalescing pending work must not delay an explicit recovery acknowledgement.
+ to.bitkit.testhook.BackupFault.event("DEBOUNCE $category") // test hook
delay(BACKUP_DEBOUNCE)
categoryMutexes.getValue(category).withLock {
runningBackups += category
@@ -988,6 +989,7 @@ class BackupRepo @Inject constructor(
temporaryFailureSince.putIfAbsent(category, backupRequired)
val retryDelay = (TEMPORARY_FAILURE_RETRY_DELAY * (1 shl (attempt - 1).coerceAtMost(MAX_RETRY_SHIFT)))
.coerceAtMost(TEMPORARY_FAILURE_RETRY_MAX_DELAY)
+ to.bitkit.testhook.BackupFault.event("RETRY $category delay=$retryDelay revision=$backupRequired") // test hook
Logger.warn("Retrying backup for: '$category' in '$retryDelay'", e, context = TAG)
pendingScope.launch(start = CoroutineStart.LAZY) {
delay(retryDelay)
diff --git a/app/src/main/java/to/bitkit/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt
index ef299b029..72236e4c9 100644
--- a/app/src/main/java/to/bitkit/services/UsdtService.kt
+++ b/app/src/main/java/to/bitkit/services/UsdtService.kt
@@ -38,6 +38,7 @@ class UsdtService @Inject constructor(
internal val recoveryBackup = object : UsdtBackup {
override suspend fun persist(snapshot: String) {
+ to.bitkit.testhook.BackupFault.event("CALLBACK_ENTER sha256=${to.bitkit.testhook.BackupFault.digest(snapshot.encodeToByteArray())}") // test hook
backupRepo.get().persistUsdtRecoveryBackup(snapshot).getOrElse {
Logger.error(
"Failed to persist USDT recovery backup with '${it.javaClass.simpleName}'",
@@ -46,6 +47,7 @@ class UsdtService @Inject constructor(
throw UsdtException.BackupUnavailable()
}
backupAcknowledgements.incrementAndGet()
+ to.bitkit.testhook.BackupFault.event("CALLBACK_ACK sha256=${to.bitkit.testhook.BackupFault.digest(snapshot.encodeToByteArray())}") // test hook
}
}
diff --git a/app/src/main/java/to/bitkit/testhook/BackupFault.kt b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
new file mode 100644
--- /dev/null
+++ b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
@@ -0,0 +1,46 @@
+// test hook: local review fixture, never shipped.
+package to.bitkit.testhook
+
+import android.util.Log
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import to.bitkit.BuildConfig
+
+object BackupFault {
+ private val gates = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
+ val events = CopyOnWriteArrayList<String>()
+ fun event(text: String) {
+ if (!BuildConfig.DEBUG) return
+ val line = "${android.os.SystemClock.elapsedRealtime()} $text"
+ events.add(line)
+ Log.i("BackupFault", line)
+ }
+ fun arm(name: String) { gates[name] = CompletableDeferred(); event("ARM $name") }
+ fun release(name: String) { gates.remove(name)?.complete(Unit); event("RELEASE $name") }
+ fun releaseAll() { gates.keys.toList().forEach { release(it) } }
+ suspend fun hold(name: String, drain: Boolean = false) {
+ if (!BuildConfig.DEBUG) return
+ val gate = gates[name] ?: return
+ event("HELD $name")
+ if (drain) withContext(NonCancellable) { gate.await() } else gate.await()
+ gates.remove(name, gate)
+ event("EXIT $name")
+ }
+ @Volatile var timeoutNext = false
+ suspend fun setupBoundary() {
+ event("SETUP_BOUNDARY")
+ if (timeoutNext) { timeoutNext = false; event("SETUP_TIMEOUT_HOLD 30s"); kotlinx.coroutines.awaitCancellation() }
+ hold("setupInside")
+ }
+ fun gateKey(key: String) = if (key.startsWith("USDT_RECOVERY_")) "USDT_RECOVERY" else key
+ suspend fun beforePut(key: String, data: ByteArray) {
+ event("PUT_ENTER $key sha256=${digest(data)}" + if (key == "SETTINGS") " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(data.decodeToString()).settings.selectedCurrency else "")
+ try { hold("put:${gateKey(key)}") }
+ finally { hold("cleanup:${gateKey(key)}", drain = true) }
+ }
+ fun digest(data: ByteArray) = MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) }
+}
diff --git a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
new file mode 100644
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -0,0 +1,133 @@
+// test hook: shell-only control surface for disposable emulator.
+package to.bitkit.dev
+
+import android.app.Activity
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.widget.TextView
+import android.widget.ScrollView
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.CacheStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.backup.VssBackupClient
+import to.bitkit.models.BackupCategory
+import to.bitkit.models.BackupItemStatus
+import to.bitkit.repositories.BackupRepo
+import to.bitkit.services.UsdtService
+import to.bitkit.testhook.BackupFault
+import java.util.concurrent.ConcurrentHashMap
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface BackupFaultDependencies {
+ fun backup(): BackupRepo
+ fun cache(): CacheStore
+ fun settings(): SettingsStore
+ fun keychain(): Keychain
+ fun vss(): VssBackupClient
+ fun clients(): to.bitkit.data.backup.VssClientCoordinator
+ fun usdt(): UsdtService
+}
+
+class BackupFaultProvider : ContentProvider() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val tasks = ConcurrentHashMap<String, Job>()
+ private val deps by lazy { EntryPointAccessors.fromApplication(requireNotNull(context), BackupFaultDependencies::class.java) }
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ check(Binder.getCallingUid() == 2000)
+ fun asyncTask(name: String, block: suspend () -> Unit) {
+ tasks[name] = scope.launch {
+ BackupFault.event("TASK_BEGIN $name")
+ try { block(); BackupFault.event("TASK_OK $name") }
+ catch (e: Exception) { BackupFault.event("TASK_ERROR $name ${e.javaClass.simpleName} cause=${e.cause?.javaClass?.simpleName}") }
+ }
+ }
+ val answer = when(method) {
+ "init" -> { asyncTask("init") {
+ check(deps.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name) == null)
+ deps.keychain().saveString(Keychain.Key.BIP39_MNEMONIC.name, org.lightningdevkit.ldknode.generateEntropyMnemonic(org.lightningdevkit.ldknode.WordCount.WORDS12))
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "prepare" -> { asyncTask("prepare") {
+ BackupFault.releaseAll(); deps.backup().setWiping(false); deps.backup().reset()
+ deps.settings().update { it.copy(selectedCurrency = "USD") }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "timeout" -> { BackupFault.timeoutNext = true; BackupFault.event("ARM_SETUP_TIMEOUT"); "ok" }
+ "cold" -> { asyncTask("cold") {
+ BackupFault.releaseAll(); deps.backup().stopObservingBackups(); deps.backup().setWiping(false)
+ deps.clients().withSuspendedClients { deps.backup().reset() }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.usdt().wallet(requiresNetwork = false)
+ }; "started" }
+ "cycle" -> { asyncTask("cycle") { repeat(5) { deps.backup().stopObservingBackups(); deps.backup().startObservingBackups(); delay(500) } }; "started" }
+ "start" -> { deps.backup().startObservingBackups(); BackupFault.event("START_REQUEST"); "ok" }
+ "stop" -> { deps.backup().stopObservingBackups(); BackupFault.event("STOP_REQUEST"); "ok" }
+ "arm" -> { BackupFault.arm(requireNotNull(arg)); "ok" }
+ "release" -> { BackupFault.release(requireNotNull(arg)); "ok" }
+ "currency" -> { asyncTask("currency") { deps.settings().update { it.copy(selectedCurrency = requireNotNull(arg)) } }; "started" }
+ "mark" -> { asyncTask("mark") { deps.cache().updateBackupStatus(BackupCategory.valueOf(requireNotNull(arg))) { it.copy(required = it.required + 1) } }; "started" }
+ "manual" -> { asyncTask("manual") { deps.backup().triggerBackup(BackupCategory.valueOf(requireNotNull(arg))).getOrThrow() }; "started" }
+ "full" -> { deps.backup().scheduleFullBackup(); BackupFault.event("FULL_REQUEST"); "ok" }
+ "usdt" -> { asyncTask(arg ?: "usdt") { deps.usdt().wallet(requiresNetwork = false); val snapshot = requireNotNull(deps.usdt().backupSnapshot()); BackupFault.event("RECOVERY_SNAPSHOT ${BackupFault.digest(snapshot.encodeToByteArray())}"); deps.usdt().recoveryBackup.persist(snapshot) }; "started" }
+ "reset" -> { asyncTask("reset") { deps.clients().withSuspendedClients(shutdown = { domain ->
+ BackupFault.event("SHUTDOWN $domain")
+ when (domain) {
+ to.bitkit.data.backup.VssClientCoordinator.Domain.APP -> com.synonym.vssclient.vssShutdownClient()
+ to.bitkit.data.backup.VssClientCoordinator.Domain.LDK -> com.synonym.vssclient.vssShutdownLdkClient()
+ }
+ }) { deps.backup().reset() } }; "started" }
+ "cancel" -> { tasks[arg]?.cancel(); BackupFault.event("CANCEL $arg"); "ok" }
+ "wipe" -> { deps.backup().setWiping(arg == "true"); BackupFault.event("WIPE $arg"); "ok" }
+ "clear" -> { BackupFault.events.clear(); "ok" }
+ "fetch" -> { asyncTask("fetch") { val item = deps.vss().getObject(requireNotNull(arg)).getOrThrow(); BackupFault.event("FETCH $arg present=${item != null} sha256=${item?.value?.let(BackupFault::digest)}" + if (arg == "SETTINGS" && item != null) " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(item.value.decodeToString()).settings.selectedCurrency else "") }; "started" }
+ "readRecovery" -> { asyncTask("readRecovery") {
+ val records = to.bitkit.data.backup.UsdtRecoveryBackupStore(deps.vss()).snapshots()
+ for (r in records) BackupFault.event("RECOVERY_READBACK sha256=${BackupFault.digest(r.usdtWallet.encodeToByteArray())}")
+ }; "started" }
+ "status" -> runBlocking(Dispatchers.IO) {
+ "ack=" + deps.usdt().recoveryGeneration + " currency=" + deps.settings().data.first().selectedCurrency + " statuses=" + deps.cache().backupStatuses.first().filterKeys { it.name == (arg ?: "SETTINGS") }.entries.joinToString { "${it.key}=${it.value}" }
+ }
+ else -> BackupFault.events.joinToString("\n")
+ }
+ if (method == "status") BackupFault.event(answer)
+ return Bundle().apply { putString("result", answer) }
+ }
+ override fun onCreate() = true
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
+ override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
+ override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
+}
+
+class BackupFaultActivity : Activity() {
+ private val handler = Handler(Looper.getMainLooper())
+ private lateinit var text: TextView
+ private val refresh = object : Runnable {
+ override fun run() {
+ val output = "Backup lifecycle fault fixture\nbe4e1ba + local setup deadline controls\nReal DataStore and native encrypted VSS\nDisposable unfunded wallet; no payments\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ if (text.text.toString() != output) text.text = output // test hook
+ handler.postDelayed(this, 250)
+ }
+ }
+ override fun onCreate(saved: Bundle?) {
+ super.onCreate(saved)
+ text = TextView(this).apply { setTextColor(Color.WHITE); setBackgroundColor(Color.BLACK); textSize = 14f; setPadding(24,60,24,20) }
+ setContentView(ScrollView(this).apply { addView(text) }); handler.post(refresh)
+ }
+ override fun onDestroy() { handler.removeCallbacks(refresh); super.onDestroy() }
+}Deciding event excerpts (elapsed milliseconds):
wallet-timeout:
215877 STOP_REQUEST
218527 ARM_SETUP_TIMEOUT
219557 TASK_BEGIN manual
219583 SETUP_ENTER
219617 SETUP_BOUNDARY
219617 SETUP_TIMEOUT_HOLD 30s
249679 RETRY WALLET delay=10s revision=1794236322797
249696 TASK_ERROR manual BackupClientSetupTimeout cause=TimeoutCancellationException
251020 ack=0 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1000, required=1794236322797)
252058 ack=0 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1000, required=1794236322797)
259701 DEBOUNCE WALLET
264724 SETUP_ENTER
264739 SETUP_BOUNDARY
265044 PUT_ENTER WALLET sha256=2633e1fbf3bee247b38ada63125b99969d483a6195ffd5a45e2a32dcac361ced
265766 PUT_OK WALLET sha256=2633e1fbf3bee247b38ada63125b99969d483a6195ffd5a45e2a32dcac361ced
267429 ack=0 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1794236367946, required=1794236367946)
268435 TASK_BEGIN fetch
268482 FETCH WALLET present=true sha256=2633e1fbf3bee247b38ada63125b99969d483a6195ffd5a45e2a32dcac361ced
268482 TASK_OK fetch
271461 ack=0 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1794236367946, required=1794236367946)
usdt-timeout:
396319 STOP_REQUEST
399162 ARM_SETUP_TIMEOUT
400154 TASK_BEGIN usdt
400179 RECOVERY_SNAPSHOT b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
400179 CALLBACK_ENTER sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
400205 SETUP_ENTER
400212 SETUP_BOUNDARY
400212 SETUP_TIMEOUT_HOLD 30s
430225 RETRY USDT_RECOVERY delay=10s revision=1794236503418
430236 TASK_ERROR usdt BackupUnavailable cause=null
435749 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1794236503418)
437447 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1794236503418)
440272 DEBOUNCE USDT_RECOVERY
445330 SETUP_ENTER
445347 SETUP_BOUNDARY
445373 PUT_ENTER USDT_RECOVERY_V1_ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a sha256=ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a
445948 PUT_OK USDT_RECOVERY_V1_ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a sha256=ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a
448164 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1794236548524, required=1794236548524)
449197 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1794236548524, required=1794236548524)
451429 TASK_BEGIN readRecovery
451429 SETUP_ENTER
451571 RECOVERY_READBACK sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
451572 TASK_OK readRecovery
454567 TASK_BEGIN retry
454592 RECOVERY_SNAPSHOT b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
454592 CALLBACK_ENTER sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
454617 SETUP_ENTER
454617 PUT_ENTER USDT_RECOVERY_V1_ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a sha256=ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a
454661 PUT_OK USDT_RECOVERY_V1_ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a sha256=ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a
454689 CALLBACK_ACK sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
454689 TASK_OK retry
457785 ack=1 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1794236557829, required=1794236557829)
wallet-cancel:
478295 STOP_REQUEST
481210 ARM setupInside
482261 TASK_BEGIN manual
482278 SETUP_ENTER
482286 SETUP_BOUNDARY
482286 HELD setupInside
484328 CANCEL manual
484348 TASK_ERROR manual JobCancellationException cause=null
486424 RELEASE setupInside
505598 ack=1 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1000, required=1794236585499)
506598 ack=1 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1000, required=1794236585499)
507613 TASK_BEGIN manual
507623 SETUP_ENTER
507630 SETUP_BOUNDARY
507749 PUT_ENTER WALLET sha256=206d537fe689fdf34517a197953ac04dfc86b584c892eb50263190daf183fb4e
508125 PUT_OK WALLET sha256=206d537fe689fdf34517a197953ac04dfc86b584c892eb50263190daf183fb4e
508143 TASK_OK manual
509643 ack=1 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1794236610849, required=1794236610849)
510648 ack=1 currency=USD statuses=WALLET=BackupItemStatus(running=false, synced=1794236610849, required=1794236610849)
usdt-retire:
540340 STOP_REQUEST
543746 ARM setupInside
544803 TASK_BEGIN retired
544828 RECOVERY_SNAPSHOT b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
544829 CALLBACK_ENTER sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
544853 SETUP_ENTER
544860 SETUP_BOUNDARY
544860 HELD setupInside
547031 TASK_BEGIN reset
547046 SHUTDOWN APP
547047 TASK_OK reset
547047 TASK_ERROR retired BackupUnavailable cause=null
550163 RELEASE setupInside
569349 ack=1 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1794236648066)
570340 ack=1 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1794236648066)
571371 TASK_BEGIN replacement
571397 RECOVERY_SNAPSHOT b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
571398 CALLBACK_ENTER sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
571435 SETUP_ENTER
571443 SETUP_BOUNDARY
571453 PUT_ENTER USDT_RECOVERY_V1_ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a sha256=ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a
572496 PUT_OK USDT_RECOVERY_V1_ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a sha256=ac347b5a070c750aa8c03842ad1b6e39a2d9b414a50cf537539405d980d5352a
572508 CALLBACK_ACK sha256=b19fb4deca270d522c413506b5503c4f765838904eb911697ec1442372ce946b
572508 TASK_OK replacement
576064 ack=2 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1794236674635, required=1794236674635)
577111 ack=2 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1794236674635, required=1794236674635)
Profile case configuration: E2E=true E2E_BACKEND=local E2E_LOCAL_HOST=127.0.0.1, with the local testnet homeserver key supplied through E2E_HOMESERVER_PUBKY. APK SHA-256 92c52fa6b506702814cacad7d48fc861721d940a516e5807b667cf070d74d0ef independently matched the installed package. The following additions apply on top of the backup control patch above; both patches were checked together against the reviewed head.
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 000c8ceba..a05252700 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -527,6 +527,7 @@ class PrivatePaykitRepo @Inject constructor(
context: String,
deferAppSync: Boolean,
): Result<Unit> = withContext(serializedDispatcher) {
+ to.bitkit.testhook.BackupFault.event("PROFILE_CLEANUP_ENTER") // test hook
runSuspendCatching {
updateContactSharingCleanupPending(true)
removePublishedEndpoints().getOrThrow()
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 41af988a8..937a0f29c 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -1114,6 +1114,7 @@ class PubkyRepo @Inject constructor(
}
suspend fun deleteProfile(): Result<Unit> = runSuspendCatching {
+ to.bitkit.testhook.BackupFault.event("PROFILE_DELETE_ENTER") // test hook
withContext(ioDispatcher) {
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
"No session available"
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index e9105a40c..f1cc003c7 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -111,6 +111,7 @@ class PubkyService @Inject constructor(
}
suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.CORE.background {
+ to.bitkit.testhook.BackupFault.event("PROFILE_SIGNIN_ENTER") // test hook
paykitSdkService.signIn(secretKeyHex)
Unit
}
--- a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -38,6 +38,8 @@
fun keychain(): Keychain
fun vss(): VssBackupClient
fun clients(): to.bitkit.data.backup.VssClientCoordinator
+ fun pubky(): to.bitkit.repositories.PubkyRepo
+ fun sdk(): to.bitkit.services.PaykitSdkService
fun usdt(): UsdtService
}
@@ -66,6 +68,13 @@
deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
deps.vss().setup().getOrThrow()
}; "started" }
+ "profile" -> { asyncTask("profile") {
+ deps.sdk().initialize()
+ deps.pubky().initialize()
+ deps.pubky().createIdentity("Disposable backup QA", "", emptyList(), emptyList(), null).getOrThrow()
+ BackupFault.event("PROFILE_CREATED ${deps.pubky().publicKey.value}")
+ }; "started" }
+ "restoreProfile" -> { asyncTask("restoreProfile") { deps.sdk().initialize(); deps.pubky().initialize(); BackupFault.event("PROFILE_RESTORED ${deps.pubky().publicKey.value}") }; "started" }
"timeout" -> { BackupFault.timeoutNext = true; BackupFault.event("ARM_SETUP_TIMEOUT"); "ok" }
"cold" -> { asyncTask("cold") {
BackupFault.releaseAll(); deps.backup().stopObservingBackups(); deps.backup().setWiping(false)
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -14,5 +14,6 @@
<!-- test hook: disposable review fixture only -->
<provider android:name="to.bitkit.dev.BackupFaultProvider" android:authorities="${applicationId}.backupfault" android:exported="true" tools:ignore="ExportedContentProvider" />
<activity android:name="to.bitkit.dev.BackupFaultActivity" android:exported="true" />
+ <activity android:name="to.bitkit.dev.ProfileDeletionActivity" android:exported="true" /> <!-- test hook -->
</application>
</manifest>
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/ProfileDeletionActivity.kt
@@ -0,0 +1,31 @@
+// test hook: actual Edit Profile screen and ViewModel in a disposable native fixture.
+package to.bitkit.dev
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.lifecycle.lifecycleScope
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import to.bitkit.testhook.BackupFault
+import to.bitkit.ui.screens.profile.EditProfileScreen
+import to.bitkit.ui.screens.profile.EditProfileViewModel
+import to.bitkit.ui.theme.AppThemeSurface
+
+@AndroidEntryPoint
+class ProfileDeletionActivity : ComponentActivity() {
+ private val model: EditProfileViewModel by viewModels()
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch { model.uiState.collect {
+ BackupFault.event("PROFILE_UI deleting=${it.isDeleting} failure=${it.showDeleteFailureDialog} confirm=${it.showDeleteDialog}")
+ } }
+ lifecycleScope.launch { model.effects.collect { BackupFault.event("PROFILE_EFFECT $it") } }
+ setContent {
+ AppThemeSurface {
+ EditProfileScreen(model, onBackClick = {}, onExitProfile = { BackupFault.event("PROFILE_EXIT") })
+ }
+ }
+ }
+}Response relay behind the fixture's existing TLS proxy; authentication headers and SDK requests remain intact. A matching DELETE is forwarded first. Once the homeserver returns success, an explicit gate withholds the reply until the app times out; releasing it afterward does not acknowledge the timed-out app operation.
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from urllib.request import Request,urlopen
from urllib.error import HTTPError
import threading,time,json
state={'armed':False,'events':[]}; lock=threading.Lock(); release=threading.Event()
def event(**v):
with lock: state['events'].append({'time':time.monotonic(),**v})
print(json.dumps(v),flush=True)
class Handler(BaseHTTPRequestHandler):
protocol_version='HTTP/1.1'
def log_message(self,*args): pass
def do_GET(self): self.relay()
def do_POST(self): self.relay()
def do_PUT(self): self.relay()
def do_DELETE(self): self.relay()
def do_LOCK(self): self.relay()
def do_UNLOCK(self): self.relay()
def do_HEAD(self): self.relay()
def relay(self):
if self.path.startswith('/control/'):
if self.path=='/control/arm': state['armed']=True; release.clear()
if self.path=='/control/release': release.set()
data=json.dumps(state).encode(); self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data);return
body=self.rfile.read(int(self.headers.get('Content-Length','0')))
headers={k:v for k,v in self.headers.items() if k.lower() not in ('connection','accept-encoding','transfer-encoding')}
req=Request('http://127.0.0.1:26286'+self.path,data=body if self.command not in ('GET','HEAD') else None,headers=headers,method=self.command)
try: r=urlopen(req,timeout=90)
except HTTPError as e: r=e
except Exception as e:
event(method=self.command,path=self.path,error=type(e).__name__);self.send_error(502);return
data=r.read(); event(method=self.command,path=self.path,status=r.status)
if state['armed'] and self.command=='DELETE' and '/pub/paykit/' in self.path and 'profile' in self.path and 200<=r.status<300:
state['armed']=False; event(accepted_delete_response_held=True,path=self.path);release.wait()
self.send_response(r.status)
for k,v in r.headers.items():
if k.lower() not in ('connection','transfer-encoding','content-length','content-encoding'):self.send_header(k,v)
self.send_header('Content-Length',str(len(data)));self.end_headers()
try:self.wfile.write(data)
except (BrokenPipeError,ConnectionResetError):pass
server=ThreadingHTTPServer(('0.0.0.0',24689),Handler)
def stop(): release.set();server.shutdown()
threading.Timer(7200,stop).start();server.serve_forever()Profile event excerpt (elapsed milliseconds):
1183107 STOP_REQUEST
1185630 PROFILE_UI deleting=false failure=false confirm=true
1187063 PROFILE_UI deleting=true failure=false confirm=false
1187067 PROFILE_CLEANUP_ENTER
1187601 PROFILE_DELETE_ENTER
1218862 PROFILE_UI deleting=true failure=true confirm=false
1218863 PROFILE_UI deleting=false failure=true confirm=false
1225214 PROFILE_UI deleting=false failure=false confirm=false
PROCESS RESTART
1246810 TASK_BEGIN restoreProfile
1256624 PROFILE_RESTORED pubkyfor8ojh7tqoae1wy6ouzqkqzs11w5awbos4kire1exfyzxyixf8o
1256625 TASK_OK restoreProfile
1259962 PROFILE_UI deleting=false failure=false confirm=false
1259962 PROFILE_UI deleting=false failure=false confirm=false
1260017 PROFILE_UI deleting=false failure=false confirm=false
Selected proxy outcomes for the posted fresh profile:
DELETE /pub/paykit/profile.json -> 204, accepted response held
accepted profile DELETE count for this identity: 1
later profile PUT/DELETE count: 0
separate backend GET after restart: 404
Reviewed by gpt-6.1-sol-high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of the complete PR diff at f8b2ffc1 against merge base 3e1fdb7d. That base is an ancestor of the head, so it is also the merge base. The saved comparison base moved from b5d5858 to 3e1fdb7 and no baseline coverage was supplied, so this pass reassessed the pinned PR and inherited nothing.
Code review completed with unverified earlier concerns.
Backup observation, reset, explicit retry, activity digest ownership, setup-timeout retry, and USDT recovery persistence match the lifecycle covered by BackupRepoTest. Removing deleteProfileWithSessionRetry stops an uncertain deletion failure from signing in again and repeating contact cleanup and profile deletion. User Retry still enters attemptDeleteProfile once per tap. Compared with bitkit-ios#1123 at 6c40b8d5: iOS still refreshes and retries the whole deletion after a session-refreshable error, and it does not carry the uncertain-response journey. That is this PR's Android behavior change, not a review of the twin. bitkit-android#1686 at 3e1fdb7 is the stacked parent; only its restore-gate interaction with this diff was checked, including activity reads failing closed instead of uploading empty lists.
Validation: BackupRepoTest, PubkyRepoTest, and EditProfileViewModelTest were inspected, not executed. Device testing: not performed in this review. Author manual cases 6–8 and the hosted runs in the PR body are tied to older commits, not this head. Manual cases 1–5 still need the fault fixture described in journeys/backup-restore/wallet-snapshot-consistency.md.
Suggested additional test cases
- Android. Disposable profile whose homeserver returns a confirmed authentication failure while the local secret key remains. Delete the profile and wait through the failure dialog without tapping Retry. Expected: no second contact cleanup, profile deletion, or sign-in, and no success toast. Tap Retry once. Expected: deletion completes, or the dialog stays failed with no success toast and no onboarding navigation.
Earlier findings reconciled at this revision: 1 still present, 3 verified fixed, 1 could not be verified, 1 tracked separately outside this PR.
Tracked separately (outside this PR; unresolved): Native LDK shutdown abort during VSS outage. The reported VSS-outage native shutdown abort remains the open ldk-node shutdown failure in #1536. From base 3e1fdb7 to head f8b2ffc this PR does not change LightningService.stop, the ldk-node-android 0.7.0-rc.78 pin, or native LDK sources. The issue stays open. This stage performed no native execution and does not treat the issue as a fix or a current-head pass. This is not a runtime verification or merge-readiness declaration.
Reconciliation incomplete: the following earlier concerns remain unverified. This report does not establish that they are fixed or invalid, or establish approval or device readiness.
Could not verify earlier concern — Signed-in outage recovery encountered Pubky lock/HTTP 405 errors: The HTTP 405 Pubky write-lock failure is an attributed device report at 9cfe052. A later attributed signed-in repeat at a3693b0 completed WALLET, METADATA, and SETTINGS and said that repeat does not explain the earlier HTTP 405 or identify a code fix. Current source still propagates Paykit snapshot failures and still refuses metadata replacement while a required WALLET backup has quarantined public addresses. Paykit Transport remains temporary, and the AuthException classifier matches only a VSS request-send string, so neither path shows a stuck HTTP 405. No current reproduction, native trace, or exact tracking issue was available in this code-only pass, so recurrence and resolution stay unverified.
Existing concerns
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — still unresolved; already tracked.
Reviewer model: grok-4.7
Reasoning effort: high
There was a problem hiding this comment.
Verdict: 💬 Comment — delta checks passed; the existing complete-WALLET availability concern remains separate.
Findings: No new findings in this delta. The previously reported terminal-identity complete-WALLET limitation remains unresolved; these cases do not establish its recovery.
QA for f8b2ffc: Android 15 / API 35 ARM64 emulator, built from this head. ACTIVITY cases used real Core reads, real DataStore and released native VSS authentication/encryption against staging with an unfunded disposable wallet. Profile cases used the actual Edit Profile screen and ViewModel with a disposable Pubky 0.15 testnet profile. Boundary holds, status assertions, upload counts and payload comparisons were checked in the device logs; the recordings show the controlled event display or actual profile UI.
Unchanged activity avoids duplicate uploads ✅ An identical observed payload synchronized without another PUT. Explicit Retry still wrote, and native decrypted readback matched that write.
The fixture emitted the repository's activity-change signal without changing its stored data. The observed run skipped the duplicate, while the following explicit request forced a fresh upload. The payload contains empty activity and tag lists.
activity-dedup-2x.2x.mp4
Observed deduplication followed by explicit Retry.
Full backup joins a held activity capture ✅ A full request arriving during capture forced a real ACTIVITY upload despite an unchanged payload; status synchronized and native readback matched.
Held the actual captured payload before the force/digest decision, requested a full backup, then released capture without another activity edit.
activity-capture-full-2x.2x.mp4
Full request during held capture.
Full backup survives a held status update ✅ A full request joined while DataStore was paused before the unchanged-result transform. Stopping observation and releasing the write still produced a follow-up PUT and synchronized status.
The hold runs inside the real DataStore update, before its transform. Upload counts and native readback verify the follow-up; the focused regression separately checks that a known full request stays pending before upload.
activity-status-before-2x.2x.mp4
Full request before the held status transform.
Full backup survives status publication ✅ A full request joined after the unchanged-result transform but before DataStore committed it. After observation stopped, a follow-up PUT still synchronized ACTIVITY.
This second hold runs after the transform and outside the lifecycle lock, while the real DataStore update remains uncommitted. No additional activity edit or observer restart was needed. Native readback matched the follow-up upload.
activity-status-after-2x.2x.mp4
Full request before publication of the computed status.
Activity retries after an uncertain upload ✅ Withholding an explicit upload's successful native result made that request fail. The next observed identical payload uploaded again and synchronized; native readback matched.
The native PUT really committed before the local control withheld its acknowledgement. The next observation made a fresh PUT instead of trusting the earlier cached digest. This covers an uncertain write of identical logical content; the committed regression also covers reverting a changed payload after failure.
activity-unknown-put-2x.2x.mp4
Committed write with its acknowledgement withheld, then observed recovery.
A replacement VSS owner uploads matching activity ✅ Retiring both native client domains and resetting the backup repository allowed the replacement owner to upload the same logical payload; status and native readback matched.
The disposable wallet data and recovery phrase stayed the same. This exercises client-owner retirement plus repository reset, rather than a UI wallet wipe or a different seed.
activity-owner-2x.2x.mp4
Owner retirement and reset followed by observed activity backup.
Activity read failure preserves the remote backup ✅ A real Core SQLite read failure left ACTIVITY pending and idle without a PUT. Native readback retained the prior bytes; repairing the table allowed explicit Retry to synchronize.
Temporarily renamed the disposable database's closed_channels table while the app was running, then restored its name. Core returned a real retrieval error before upload setup. The prior remote payload was empty, and its SHA-256 stayed identical during failure. The Lightning node was never started in this fixture. This is logical schema restoration, not byte-identical database restoration or remote wallet restore.
activity-read-refusal-2x.2x.mp4
Real local read refusal, preserved remote backup and successful Retry.
Uncertain profile deletion does not repeat ✅ A real profile DELETE returned 204 to the proxy, whose response was withheld until timeout. Dismissing the failure dialog and restarting produced no second deletion, cleanup, app-level sign-in or success effect.
The actual Edit Profile screen showed Retry and Disconnect. Back dismissed the dialog. Device logs recorded one cleanup and one deletion, with no further attempt during 17 seconds before restart or 17 seconds after session restoration and screen reopening. The proxy recorded one accepted deletion and no later profile write during the case; an independent backend GET returned 404. Native session-restoration traffic is distinct from the app-level sign-in counter. This uses the controlled screen host, rather than the full MainActivity navigation host.
profile-uncertain-2x.2x.mp4
Actual failure dialog, dismissal and process restart.
Profile deletion authentication failure waits for Retry ✅ A confirmed HTTP 401 showed the failure dialog without an automatic sign-in or repeated cleanup/deletion. One user Retry, after permission was restored, completed one fresh attempt.
The disposable identity retained its local secret and session. The response proxy refused the actual profile DELETE with 401 before forwarding it. During the subsequent 17-second observation, logs showed one cleanup and one deletion, with no success effect. Retry produced exactly one additional cleanup/deletion, a backend 204, and the screen’s DeleteSuccess/exit callback. No app-level sign-in occurred.
profile-auth-2x.2x.mp4
Confirmed authentication failure followed by one explicit Retry.
Check: focused backup and parent regressions ✅ The unmodified head built, and the focused backup, USDT, Lightning and Pubky service suites passed.
Ran assembleDevDebug and :app:testDevDebugUnitTest with BackupRepoTest, UsdtRepoTest, LightningRepoTest and PubkyServiceTest. This includes the two paused-status controls and the changed payload, owner, reset and explicit ACTIVITY regressions. This is component coverage, separate from the installed cases above.
No planned case remains unrun. Manual 1–5 retain the recorded lifecycle and signed-operation controls at 1cf5d39, with their disclosed reset and acknowledgement limits. The unchanged WALLET/USDT setup-timeout, cancellation and retirement paths retain the be4e1ba executions. Manual 6, 7B and 8 retain their revision-specific evidence in the PR description and the authentication transport report. The ACTIVITY read-refusal case above rechecks the changed Manual 7A path. The historical signed-in Pubky HTTP 405 remains unexplained; this local profile fixture does not establish its production resolution.
Twin: synonymdev/bitkit-ios#1123 at 6c40b8d5 matches temporary setup-timeout retry and the ownership/drain contracts. Its ACTIVITY backup still uploads unconditionally, so it does not yet share this Android deduplication/full-request implementation. No iOS device execution is claimed.
Review covered the contiguous be4e1ba..f8b2ffc delta, reached lifecycle/status/restore callers and prior open concerns. Imported parent changes received code and focused regression review; these recordings do not drive the separate Lightning capacity, hardware funding, USDT receipt-celebration or receiving-address journeys. No literal wallet wipe/restore, payment broadcast, process-death payment replay or chain-finality claim is added.
Local controls and build binding
The controls are not pushed. They hold real capture/DataStore boundaries, invoke existing repository operations, display actual events, and withhold one native PUT acknowledgement after its real success. They do not supply a successful SDK result or alter the production digest, retry, status, cancellation or acknowledgement decisions. The profile proxy preserves SDK requests and authentication headers; it either withholds a real successful DELETE response or injects the disclosed 401. The profile activity hosts the existing screen and ViewModel.
ARM64 APK SHA-256:
- Unmodified head:
1d96559da2e0802798748fa96c06adb34ab305c1276d7f5fafbcc8afcc168903. - ACTIVITY control build:
bea165d4bd5d763ed92604e786e2eb189e881311b0f5cc24ecd84f3bfb49a4ba. - Local-testnet profile control build:
fc86582edcf7699c697ef36559b544e94d84b4456567d382a59cf46c5b4cb704.
Both installed control APKs independently matched their build hashes. Profile build configuration: E2E=true E2E_BACKEND=local E2E_LOCAL_HOST=127.0.0.1, with the fixture homeserver key supplied through E2E_HOMESERVER_PUBKY.
Full app control patch:
diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml
index b88fe73a7..a7d600b8a 100644
--- a/app/src/debug/AndroidManifest.xml
+++ b/app/src/debug/AndroidManifest.xml
@@ -11,5 +11,9 @@
android:authorities="${applicationId}.devtools"
android:exported="true"
tools:ignore="ExportedContentProvider" />
+ <!-- test hook: disposable review fixture only -->
+ <provider android:name="to.bitkit.dev.BackupFaultProvider" android:authorities="${applicationId}.backupfault" android:exported="true" tools:ignore="ExportedContentProvider" />
+ <activity android:name="to.bitkit.dev.BackupFaultActivity" android:exported="true" />
+ <activity android:name="to.bitkit.dev.ProfileDeletionActivity" android:exported="true" />
</application>
</manifest>
diff --git a/app/src/main/java/to/bitkit/data/CacheStore.kt b/app/src/main/java/to/bitkit/data/CacheStore.kt
index 1c55b1894..2b866bd2b 100644
--- a/app/src/main/java/to/bitkit/data/CacheStore.kt
+++ b/app/src/main/java/to/bitkit/data/CacheStore.kt
@@ -38,7 +38,15 @@ class CacheStore internal constructor(
val backupStatuses: Flow<Map<BackupCategory, BackupItemStatus>> = data.map { it.backupStatuses }
suspend fun update(transform: (AppCacheData) -> AppCacheData) {
- store.updateData(transform)
+ to.bitkit.testhook.BackupFault.hold("cache") // test hook
+ val controlled = to.bitkit.testhook.BackupFault.statusBoundary.compareAndSet(true, false)
+ store.updateData { previous ->
+ if (controlled) to.bitkit.testhook.BackupFault.block("statusBefore")
+ val updated = transform(previous)
+ if (controlled) to.bitkit.testhook.BackupFault.block("statusAfter")
+ updated
+ }
+ to.bitkit.testhook.BackupFault.hold("cacheAfter", drain = true) // test hook
}
suspend fun setOnchainAddress(address: String) {
diff --git a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
index aaa167768..9da12c94e 100644
--- a/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
+++ b/app/src/main/java/to/bitkit/data/backup/VssBackupClient.kt
@@ -47,12 +47,15 @@ class VssBackupClient @Inject constructor(
private val keychain: Keychain,
) {
suspend fun setup(walletIndex: Int = 0): Result<Unit> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.event("SETUP_ENTER") // test hook
+ to.bitkit.testhook.BackupFault.hold("setup", drain = true) // test hook
runCatching {
coordinator.setup(VssClientCoordinator.Domain.APP) {
val mnemonic = keychain.loadString(Keychain.Key.BIP39_MNEMONIC.name)
?: throw MnemonicNotAvailableException()
withTimeout(30.seconds) {
+ to.bitkit.testhook.BackupFault.setupBoundary() // test hook
Logger.debug("VSS client setting up…", context = TAG)
val vssUrl = Env.vssServerUrl
val lnurlAuthServerUrl = Env.lnurlAuthServerUrl
@@ -121,6 +124,7 @@ class VssBackupClient @Inject constructor(
key: String,
data: ByteArray,
): Result<VssItem> = withContext(ioDispatcher) {
+ to.bitkit.testhook.BackupFault.beforePut(key, data) // test hook
Logger.verbose("VSS 'putObject' call for '$key'", context = TAG)
runSuspendCatching {
coordinator.withClient(VssClientCoordinator.Domain.APP) {
@@ -130,8 +134,16 @@ class VssBackupClient @Inject constructor(
)
}
}.onSuccess {
+ to.bitkit.testhook.BackupFault.event("PUT_OK $key sha256=${to.bitkit.testhook.BackupFault.digest(data)}") // test hook
+ if (key == "ACTIVITY" && to.bitkit.testhook.BackupFault.loseActivityAck) {
+ to.bitkit.testhook.BackupFault.loseActivityAck = false
+ to.bitkit.testhook.BackupFault.event("ACK_WITHHELD ACTIVITY")
+ throw java.io.IOException("test hook: response withheld after remote success")
+ }
+ to.bitkit.testhook.BackupFault.hold("post:${to.bitkit.testhook.BackupFault.gateKey(key)}", drain = true) // test hook
Logger.verbose("VSS 'putObject' success for '$key' at version: '${it.version}'", context = TAG)
}.onFailure {
+ to.bitkit.testhook.BackupFault.event("PUT_ERROR $key ${it.javaClass.simpleName}") // test hook
Logger.verbose("VSS 'putObject' error for '$key'", it, context = TAG)
}
}
diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
index f379140e6..15aa87c82 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -635,6 +635,7 @@ class BackupRepo @Inject constructor(
updateBackupStatus(category, expectedGeneration) { it.copy(running = true) }
owner?.ready?.await()
// Coalescing pending work must not delay an explicit recovery acknowledgement.
+ to.bitkit.testhook.BackupFault.event("DEBOUNCE $category") // test hook
delay(BACKUP_DEBOUNCE)
categoryMutexes.getValue(category).withLock {
runningBackups += category
@@ -1025,6 +1026,7 @@ class BackupRepo @Inject constructor(
temporaryFailureSince.putIfAbsent(category, backupRequired)
val retryDelay = (TEMPORARY_FAILURE_RETRY_DELAY * (1 shl (attempt - 1).coerceAtMost(MAX_RETRY_SHIFT)))
.coerceAtMost(TEMPORARY_FAILURE_RETRY_MAX_DELAY)
+ to.bitkit.testhook.BackupFault.event("RETRY $category delay=$retryDelay revision=$backupRequired") // test hook
Logger.warn("Retrying backup for: '$category' in '$retryDelay'", e, context = TAG)
pendingScope.launch(start = CoroutineStart.LAZY) {
delay(retryDelay)
@@ -1096,6 +1098,8 @@ class BackupRepo @Inject constructor(
val category = BackupCategory.ACTIVITY
val cache = activityUploadCache
val payload = getActivityBackupPayload()
+ to.bitkit.testhook.BackupFault.event("ACTIVITY_CAPTURE") // test hook
+ to.bitkit.testhook.BackupFault.hold("capture") // test hook
val digest = MessageDigest.getInstance("SHA-256")
.digest(json.encodeToString(payload.copy(createdAt = 0)).toByteArray())
val force = explicitAttempt || synchronized(lifecycleLock) {
@@ -1104,6 +1108,10 @@ class BackupRepo @Inject constructor(
if (!force && cache === activityUploadCache && cache.ownerGeneration == owner.native.generation &&
digest.contentEquals(cache.digest)
) {
+ to.bitkit.testhook.BackupFault.event("ACTIVITY_UNCHANGED") // test hook
+ if (to.bitkit.testhook.BackupFault.hasGate("statusBefore") || to.bitkit.testhook.BackupFault.hasGate("statusAfter")) {
+ to.bitkit.testhook.BackupFault.statusBoundary.set(true) // test hook
+ }
return@runSuspendCatching false
}
diff --git a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
index 000c8ceba..a05252700 100644
--- a/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PrivatePaykitRepo.kt
@@ -527,6 +527,7 @@ class PrivatePaykitRepo @Inject constructor(
context: String,
deferAppSync: Boolean,
): Result<Unit> = withContext(serializedDispatcher) {
+ to.bitkit.testhook.BackupFault.event("PROFILE_CLEANUP_ENTER") // test hook
runSuspendCatching {
updateContactSharingCleanupPending(true)
removePublishedEndpoints().getOrThrow()
diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
index 41af988a8..937a0f29c 100644
--- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt
@@ -1114,6 +1114,7 @@ class PubkyRepo @Inject constructor(
}
suspend fun deleteProfile(): Result<Unit> = runSuspendCatching {
+ to.bitkit.testhook.BackupFault.event("PROFILE_DELETE_ENTER") // test hook
withContext(ioDispatcher) {
requireNotNull(keychain.loadString(Keychain.Key.PAYKIT_SESSION.name)) {
"No session available"
diff --git a/app/src/main/java/to/bitkit/services/PubkyService.kt b/app/src/main/java/to/bitkit/services/PubkyService.kt
index 413fb3c90..97abcc80a 100644
--- a/app/src/main/java/to/bitkit/services/PubkyService.kt
+++ b/app/src/main/java/to/bitkit/services/PubkyService.kt
@@ -111,6 +111,7 @@ class PubkyService @Inject constructor(
}
suspend fun signIn(secretKeyHex: String): Unit = ServiceQueue.PUBKY.background {
+ to.bitkit.testhook.BackupFault.event("PROFILE_SIGNIN_ENTER") // test hook
paykitSdkService.signIn(secretKeyHex)
Unit
}
diff --git a/app/src/main/java/to/bitkit/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt
index ef299b029..72236e4c9 100644
--- a/app/src/main/java/to/bitkit/services/UsdtService.kt
+++ b/app/src/main/java/to/bitkit/services/UsdtService.kt
@@ -38,6 +38,7 @@ class UsdtService @Inject constructor(
internal val recoveryBackup = object : UsdtBackup {
override suspend fun persist(snapshot: String) {
+ to.bitkit.testhook.BackupFault.event("CALLBACK_ENTER sha256=${to.bitkit.testhook.BackupFault.digest(snapshot.encodeToByteArray())}") // test hook
backupRepo.get().persistUsdtRecoveryBackup(snapshot).getOrElse {
Logger.error(
"Failed to persist USDT recovery backup with '${it.javaClass.simpleName}'",
@@ -46,6 +47,7 @@ class UsdtService @Inject constructor(
throw UsdtException.BackupUnavailable()
}
backupAcknowledgements.incrementAndGet()
+ to.bitkit.testhook.BackupFault.event("CALLBACK_ACK sha256=${to.bitkit.testhook.BackupFault.digest(snapshot.encodeToByteArray())}") // test hook
}
}
diff --git a/app/src/main/java/to/bitkit/testhook/BackupFault.kt b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
new file mode 100644
index 000000000..695644fc1
--- /dev/null
+++ b/app/src/main/java/to/bitkit/testhook/BackupFault.kt
@@ -0,0 +1,57 @@
+// test hook: local review fixture, never shipped.
+package to.bitkit.testhook
+
+import android.util.Log
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+import java.security.MessageDigest
+import java.util.concurrent.ConcurrentHashMap
+import java.util.concurrent.CopyOnWriteArrayList
+import to.bitkit.BuildConfig
+
+object BackupFault {
+ private val gates = ConcurrentHashMap<String, CompletableDeferred<Unit>>()
+ val events = CopyOnWriteArrayList<String>()
+ fun event(text: String) {
+ if (!BuildConfig.DEBUG) return
+ val line = "${android.os.SystemClock.elapsedRealtime()} $text"
+ events.add(line)
+ Log.i("BackupFault", line)
+ }
+ fun arm(name: String) { gates[name] = CompletableDeferred(); event("ARM $name") }
+ fun release(name: String) { gates.remove(name)?.complete(Unit); event("RELEASE $name") }
+ fun releaseAll() { gates.keys.toList().forEach { release(it) } }
+ suspend fun hold(name: String, drain: Boolean = false) {
+ if (!BuildConfig.DEBUG) return
+ val gate = gates[name] ?: return
+ event("HELD $name")
+ if (drain) withContext(NonCancellable) { gate.await() } else gate.await()
+ gates.remove(name, gate)
+ event("EXIT $name")
+ }
+ val statusBoundary = java.util.concurrent.atomic.AtomicBoolean(false)
+ @Volatile var loseActivityAck = false
+ fun hasGate(name: String) = gates.containsKey(name)
+ fun block(name: String) {
+ if (!BuildConfig.DEBUG || !hasGate(name)) return
+ event("HELD $name")
+ val deadline = android.os.SystemClock.elapsedRealtime() + 120000
+ while (hasGate(name) && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(20)
+ check(!hasGate(name)) { "test gate exceeded 120 seconds" }
+ event("EXIT $name")
+ }
+ @Volatile var timeoutNext = false
+ suspend fun setupBoundary() {
+ event("SETUP_BOUNDARY")
+ if (timeoutNext) { timeoutNext = false; event("SETUP_TIMEOUT_HOLD 30s"); kotlinx.coroutines.awaitCancellation() }
+ hold("setupInside")
+ }
+ fun gateKey(key: String) = if (key.startsWith("USDT_RECOVERY_")) "USDT_RECOVERY" else key
+ suspend fun beforePut(key: String, data: ByteArray) {
+ event("PUT_ENTER $key sha256=${digest(data)}" + if (key == "SETTINGS") " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(data.decodeToString()).settings.selectedCurrency else "")
+ try { hold("put:${gateKey(key)}") }
+ finally { hold("cleanup:${gateKey(key)}", drain = true) }
+ }
+ fun digest(data: ByteArray) = MessageDigest.getInstance("SHA-256").digest(data).joinToString("") { "%02x".format(it) }
+}
diff --git a/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
new file mode 100644
index 000000000..6e1c4175f
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -0,0 +1,151 @@
+// test hook: shell-only control surface for disposable emulator.
+package to.bitkit.dev
+
+import android.app.Activity
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.net.Uri
+import android.os.Binder
+import android.os.Bundle
+import android.graphics.Color
+import android.os.Handler
+import android.os.Looper
+import android.widget.TextView
+import android.widget.ScrollView
+import dagger.hilt.EntryPoint
+import dagger.hilt.InstallIn
+import dagger.hilt.components.SingletonComponent
+import dagger.hilt.android.EntryPointAccessors
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.first
+import to.bitkit.data.CacheStore
+import to.bitkit.data.SettingsStore
+import to.bitkit.data.keychain.Keychain
+import to.bitkit.data.backup.VssBackupClient
+import to.bitkit.models.BackupCategory
+import to.bitkit.models.BackupItemStatus
+import to.bitkit.repositories.BackupRepo
+import to.bitkit.services.UsdtService
+import to.bitkit.testhook.BackupFault
+import java.util.concurrent.ConcurrentHashMap
+
+@EntryPoint
+@InstallIn(SingletonComponent::class)
+interface BackupFaultDependencies {
+ fun pubky(): to.bitkit.repositories.PubkyRepo
+ fun sdk(): to.bitkit.services.PaykitSdkService
+ fun activity(): to.bitkit.repositories.ActivityRepo
+ fun backup(): BackupRepo
+ fun cache(): CacheStore
+ fun settings(): SettingsStore
+ fun keychain(): Keychain
+ fun vss(): VssBackupClient
+ fun clients(): to.bitkit.data.backup.VssClientCoordinator
+ fun usdt(): UsdtService
+}
+
+class BackupFaultProvider : ContentProvider() {
+ private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+ private val tasks = ConcurrentHashMap<String, Job>()
+ private val deps by lazy { EntryPointAccessors.fromApplication(requireNotNull(context), BackupFaultDependencies::class.java) }
+ override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
+ check(Binder.getCallingUid() == 2000)
+ fun asyncTask(name: String, block: suspend () -> Unit) {
+ tasks[name] = scope.launch {
+ BackupFault.event("TASK_BEGIN $name")
+ try { block(); BackupFault.event("TASK_OK $name") }
+ catch (e: Exception) { BackupFault.event("TASK_ERROR $name ${e.javaClass.simpleName} cause=${e.cause?.javaClass?.simpleName}") }
+ }
+ }
+ val answer = when(method) {
+ "init" -> { asyncTask("init") {
+ check(deps.keychain().loadString(Keychain.Key.BIP39_MNEMONIC.name) == null)
+ deps.keychain().saveString(Keychain.Key.BIP39_MNEMONIC.name, org.lightningdevkit.ldknode.generateEntropyMnemonic(org.lightningdevkit.ldknode.WordCount.WORDS12))
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "prepare" -> { asyncTask("prepare") {
+ BackupFault.releaseAll(); deps.backup().setWiping(false); deps.backup().reset()
+ deps.settings().update { it.copy(selectedCurrency = "USD") }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.vss().setup().getOrThrow()
+ }; "started" }
+ "profile" -> { asyncTask("profile") {
+ deps.sdk().initialize()
+ deps.pubky().initialize()
+ deps.pubky().createIdentity("Disposable backup QA", "", emptyList(), emptyList(), null).getOrThrow()
+ BackupFault.event("PROFILE_CREATED ${deps.pubky().publicKey.value}")
+ }; "started" }
+ "restoreProfile" -> { asyncTask("restoreProfile") { deps.sdk().initialize(); deps.pubky().initialize(); BackupFault.event("PROFILE_RESTORED ${deps.pubky().publicKey.value}") }; "started" }
+ "activity" -> { asyncTask("activity") { deps.activity().resetState() }; "started" }
+ "loseAck" -> { BackupFault.loseActivityAck = true; BackupFault.event("ARM_LOST_ACK"); "ok" }
+ "note" -> { BackupFault.event(requireNotNull(arg)); "ok" }
+ "timeout" -> { BackupFault.timeoutNext = true; BackupFault.event("ARM_SETUP_TIMEOUT"); "ok" }
+ "cold" -> { asyncTask("cold") {
+ BackupFault.releaseAll(); deps.backup().stopObservingBackups(); deps.backup().setWiping(false)
+ deps.clients().withSuspendedClients { deps.backup().reset() }
+ deps.cache().update { it.copy(backupStatuses = BackupCategory.entries.associateWith { BackupItemStatus(required = 1000, synced = 1000) }) }
+ deps.usdt().wallet(requiresNetwork = false)
+ }; "started" }
+ "cycle" -> { asyncTask("cycle") { repeat(5) { deps.backup().stopObservingBackups(); deps.backup().startObservingBackups(); delay(500) } }; "started" }
+ "start" -> { deps.backup().startObservingBackups(); BackupFault.event("START_REQUEST"); "ok" }
+ "stop" -> { deps.backup().stopObservingBackups(); BackupFault.event("STOP_REQUEST"); "ok" }
+ "arm" -> { BackupFault.arm(requireNotNull(arg)); "ok" }
+ "release" -> { BackupFault.release(requireNotNull(arg)); "ok" }
+ "currency" -> { asyncTask("currency") { deps.settings().update { it.copy(selectedCurrency = requireNotNull(arg)) } }; "started" }
+ "mark" -> { asyncTask("mark") { deps.cache().updateBackupStatus(BackupCategory.valueOf(requireNotNull(arg))) { it.copy(required = it.required + 1) } }; "started" }
+ "manual" -> { asyncTask("manual") { deps.backup().triggerBackup(BackupCategory.valueOf(requireNotNull(arg))).getOrThrow() }; "started" }
+ "full" -> { deps.backup().scheduleFullBackup(); BackupFault.event("FULL_REQUEST"); "ok" }
+ "usdt" -> { asyncTask(arg ?: "usdt") { deps.usdt().wallet(requiresNetwork = false); val snapshot = requireNotNull(deps.usdt().backupSnapshot()); BackupFault.event("RECOVERY_SNAPSHOT ${BackupFault.digest(snapshot.encodeToByteArray())}"); deps.usdt().recoveryBackup.persist(snapshot) }; "started" }
+ "reset" -> { asyncTask("reset") { deps.clients().withSuspendedClients(shutdown = { domain ->
+ BackupFault.event("SHUTDOWN $domain")
+ when (domain) {
+ to.bitkit.data.backup.VssClientCoordinator.Domain.APP -> com.synonym.vssclient.vssShutdownClient()
+ to.bitkit.data.backup.VssClientCoordinator.Domain.LDK -> com.synonym.vssclient.vssShutdownLdkClient()
+ }
+ }) { deps.backup().reset() } }; "started" }
+ "cancel" -> { tasks[arg]?.cancel(); BackupFault.event("CANCEL $arg"); "ok" }
+ "wipe" -> { deps.backup().setWiping(arg == "true"); BackupFault.event("WIPE $arg"); "ok" }
+ "clear" -> { BackupFault.events.clear(); "ok" }
+ "fetch" -> { asyncTask("fetch") { val item = deps.vss().getObject(requireNotNull(arg)).getOrThrow(); BackupFault.event("FETCH $arg present=${item != null} sha256=${item?.value?.let(BackupFault::digest)}" + if (arg == "SETTINGS" && item != null) " currency=" + to.bitkit.di.json.decodeFromString<to.bitkit.models.SettingsBackupV1>(item.value.decodeToString()).settings.selectedCurrency else "") }; "started" }
+ "readActivity" -> { asyncTask("readActivity") {
+ val item = requireNotNull(deps.vss().getObject("ACTIVITY").getOrThrow())
+ val payload = to.bitkit.di.json.decodeFromString<to.bitkit.models.ActivityBackupV1>(item.value.decodeToString())
+ BackupFault.event("ACTIVITY_READBACK sha256=${BackupFault.digest(item.value)} records=${payload.activities.size} tags=${payload.activityTags.size} createdAt=${payload.createdAt}")
+ }; "started" }
+ "readRecovery" -> { asyncTask("readRecovery") {
+ val records = to.bitkit.data.backup.UsdtRecoveryBackupStore(deps.vss()).snapshots()
+ for (r in records) BackupFault.event("RECOVERY_READBACK sha256=${BackupFault.digest(r.usdtWallet.encodeToByteArray())}")
+ }; "started" }
+ "status" -> runBlocking(Dispatchers.IO) {
+ "ack=" + deps.usdt().recoveryGeneration + " currency=" + deps.settings().data.first().selectedCurrency + " statuses=" + deps.cache().backupStatuses.first().filterKeys { it.name == (arg ?: "SETTINGS") }.entries.joinToString { "${it.key}=${it.value}" }
+ }
+ else -> BackupFault.events.joinToString("\n")
+ }
+ if (method == "status") BackupFault.event(answer)
+ return Bundle().apply { putString("result", answer) }
+ }
+ override fun onCreate() = true
+ override fun getType(uri: Uri): String? = null
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+ override fun delete(uri: Uri, sel: String?, args: Array<String>?) = 0
+ override fun update(uri: Uri, values: ContentValues?, sel: String?, args: Array<String>?) = 0
+ override fun query(uri: Uri, proj: Array<String>?, sel: String?, args: Array<String>?, sort: String?) = null
+}
+
+class BackupFaultActivity : Activity() {
+ private val handler = Handler(Looper.getMainLooper())
+ private lateinit var text: TextView
+ private val refresh = object : Runnable {
+ override fun run() {
+ val output = "Backup lifecycle fault fixture\nf8b2ffc + local ACTIVITY boundary controls\nReal DataStore and native encrypted VSS\nDisposable unfunded wallet; no payments\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ if (text.text.toString() != output) text.text = output // test hook
+ handler.postDelayed(this, 250)
+ }
+ }
+ override fun onCreate(saved: Bundle?) {
+ super.onCreate(saved)
+ text = TextView(this).apply { setTextColor(Color.WHITE); setBackgroundColor(Color.BLACK); textSize = 14f; setPadding(24,60,24,20) }
+ setContentView(ScrollView(this).apply { addView(text) }); handler.post(refresh)
+ }
+ override fun onDestroy() { handler.removeCallbacks(refresh); super.onDestroy() }
+}
diff --git a/app/src/debug/java/to/bitkit/dev/ProfileDeletionActivity.kt b/app/src/debug/java/to/bitkit/dev/ProfileDeletionActivity.kt
new file mode 100644
index 000000000..a8f1e6cbd
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/ProfileDeletionActivity.kt
@@ -0,0 +1,29 @@
+// test hook: actual Edit Profile screen and ViewModel in a disposable native fixture.
+package to.bitkit.dev
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.lifecycle.lifecycleScope
+import dagger.hilt.android.AndroidEntryPoint
+import kotlinx.coroutines.launch
+import to.bitkit.testhook.BackupFault
+import to.bitkit.ui.screens.profile.EditProfileScreen
+import to.bitkit.ui.screens.profile.EditProfileViewModel
+import to.bitkit.ui.theme.AppThemeSurface
+@AndroidEntryPoint
+class ProfileDeletionActivity : ComponentActivity() {
+ private val model: EditProfileViewModel by viewModels()
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ lifecycleScope.launch { model.uiState.collect {
+ BackupFault.event("PROFILE_UI deleting=${it.isDeleting} failure=${it.showDeleteFailureDialog} confirm=${it.showDeleteDialog}")
+ } }
+ lifecycleScope.launch { model.effects.collect { BackupFault.event("PROFILE_EFFECT $it") } }
+ setContent {
+ AppThemeSurface {
+ EditProfileScreen(model, onBackClick = {}, onExitProfile = { BackupFault.event("PROFILE_EXIT") })
+ }
+ }
+ }
+}Reviewed by gpt-6.1-sol-high
November 9 · full ACTIVITY backup requests on
f8b2ffc1On parent
3e1fdb7d, an observed activity backup can skip an identical payload already written for that wallet. A full-backup request received during the read still forces a PUT. When that request is known before the status update, the stored revision stays pending until a real upload succeeds. When it arrives while the status update is being persisted, the worker schedules a follow-up for the same owner. Unknown PUT outcomes invalidate the cached digest. A newly arriving request has an in-memory interval before its own revision is persisted.Two paused-DataStore controls reproduced the lost upload and the premature clean status. Both passed on the final tree, along with the other 169 focused tests. The full local suite passed 5,045 tests with zero failures or skips. Final format, compile and lint passed. GitHub verified all 30 commits against the published parent. The earlier retry on
be4e1ba8is documented below.The first hosted run passed 5,045 JVM tests, 256 Compose tests and 39 E2E cases. One E2E case was explicitly disabled. All seven E2E shards passed on their first internal attempt using external suite
265a0500. The six migration jobs skipped. The passing LNURL case logged a channel-closing fee disagreement. The native backup unit controls and these app/UI checks have separate coverage. CI · E2E · LintNovember 9 · WALLET and USDT recovery upload retries
WALLET and USDT recovery uploads now take the same temporary-failure path as other uploads when VSS client setup reaches its own deadline. The pending backup stays eligible for retry. Wallet retirement and caller cancellation still stop the original operation.
At
1cf5d39e, both new controls returnedBackupUnavailableinstead of a temporary error. Atbe4e1ba8, each control verified that setup waited for the backoff and debounce before a second attempt. WALLET wrote once, and USDT wrote the original pending snapshot to an immutable recovery key.The final composed tree
5c9e091fincludes parent #1686 atb5d5858d. Required format, compile, full unit tests and lint passed. All 5,017 JVM tests passed across 265 suites with zero skips. Detekt and Android Lint reported zero findings. The inputs matched at all four gates, and all 29 PR commits verified locally and on GitHub. The setup timeout controls use a mocked native client. Hosted CI passed all 5,017 unit and 256 Compose cases. Hosted lint passed. The original E2E run had 34 passing cases, five failures and one disabled case. Four sends failed after Blocktank returned HTTP 502 during fee lookup. The send-max case used the fallback fee and displayed a different amount than the test expected. Each failure recurred on all three built-in attempts. The fee lookup and send code match the parent. Migration was skipped.On the authorized failed-job retry, all 14 cases in the three affected shards passed on their first attempt, and the dependent gate passed. Including the earlier successful shards, 39 cases passed and one was disabled. The three shards verified the original APK. Their external E2E checkout advanced from
0e83ea6ato265a0500, including a send-max balance wait. The later run does not isolate the cause of recovery. Retry run CI · Lint · E2E The terminal-identity backup limitation and existing evidence requests below remain open.External recovery evidence — November 4
The new external review at
1cf5d39adds recorded Manual 1–5 executions. An independent evidence check verified the exact five-file source delta, applicability of both disclosed control patches, and all ten new clips. The visible records corroborate queued Settings moving from Running to Latest Backup, held cleanup and replacement admission, retained settings revisions, stopped-observer retry, cancellation without callback acknowledgement, and the same recovery-envelope/plan digests receiving an acknowledgement on native refresh.These remain attributed external executions with explicit limits. The original APK/install receipts, raw proxy journal and fork counters were not provided; the reported 0 → 0 → 1 submissions are not independently verified, and the signed-operation recording still shows PENDING. The cancelled coordinator-retirement case does not execute its subsequent repository-reset lambda; the separate JPY case directly exercises
BackupRepo.reset(). No owner runtime pass, literal current-head wallet wipe/restore, process-death replay or chain finality is added. Terminal complete-WALLET availability and WALLET/USDT setup-timeout bookkeeping remain open. The existing artifact request already covers the missing receipts, so no duplicate request was sent. Local evidence:work/validation/android1689-review-5774734216-20261104/REPORT.mdandowner-integrity.json(66 verified members).Admitted-backup status correction — November 4
Signed additive head
1cf5d39e90f7e5c7e3ac4c6501ffb725523bfa2dincorporates live parent #1686 ata2c58f2427635719b63263b9441b8d22a7b6c858. A reserved replacement backup now preserves Running only after its original wallet admits it. Retirement before dispatch therefore cannot strand Running after the previous job drains. The child retains its lifecycle lock, originalBackupOwner, exact-job cleanup and coalesced-request fallback. One regression exercises both retirement orderings; the earlier liveness-only correction fails the retained negative control.Final
just format,just compile, fulljust testandjust lintpassed on 1,802 identical inputs: 4,938 unit tests across 264 suites, zero failures/errors/skips, and eight fresh static reports with zero findings. The compile task reused matching outputs; it is not described as a second compilation. Complete logs, per-case XML/HTML, SARIF and nested Gradle reports were inspected. Four existing cursor-close chains, three JVM notices and historical Gradle deprecation reports match verified parent evidence. Two affected concurrency reviews are clean. The owner verified all 1,318 sealed audit files; both private untracked probes remain unchanged and outside the commits.All 27 commits against the live parent verify locally and on GitHub (
verified=true,reason=valid); published history anddraft=falseare preserved. Current-head hosted CI, Lint and E2E passed: 4,938 units, 256 Compose tests, zero static findings and 39 unique E2E passes, with one explicitly disabled Settings case. E2Esend_1first failed waiting for the expired-invoice toast, then passed its built-in second attempt;send_2/3retained their first-attempt passes and were excluded from retry. The original input contained the previous address concatenated with the invoice despite clear/set calls; the precise input mechanism remains unresolved. The original failure andcontinue-on-erroroutcome are retained, so this is not a first-attempt-clean result. Six migration jobs were skipped. Complete raw/nested diagnostics, all 12 digest/CRC-verified archives and 1,063 sealed evidence files were inspected and verified; synthetic checkout matches this exact head tree. Existing warnings and an unmatched cancellation observation remain documented. Evidence:work/validation/hosted-android1689-1cf5-20261104/report.md. Parent J3 Settings evidence remains parent evidence, not a new child-device run. Existing authenticated recovery results and their revision limits remain below. Terminal complete-WALLET availability, running-LDK quiescence, and the previously reported WALLET/USDT_RECOVERY setup-timeout bookkeeping caveat are not resolved by this status correction.Evidence:
work/validation/android1689-admitted-parent-final-audit-20261104/report.mdandwork/validation/android1689-admitted-parent-20261104/publication.json.Original-wallet backup ownership — November 4
Signed additive merge
1a6048a41a44e2e6fc34dda20a13ff073a112925incorporates live parent #1686 at4056d2c0036e5a4e51698e26b3d1615825dd77f5. Scheduled payload capture, uploads, retry timers and cache cleanup retain their original VSS owner. A retired request cannot act on a replacement wallet or hide its newer request. The observer controller can rearm after identity replacement, while admitted work and consumed cache writes drain before the old standalone clients retire.The composition preserves independent listeners when one startup cache update fails. It also preserves an explicit request coalesced into an observer job that is canceled before dispatch: the completion fallback transfers that pending request only for the same live wallet, without duplicating an entered job's normal cleanup. The corrected deterministic control passes with this fallback and fails when only its registration is disabled. Earlier dispatcher-harness failures are retained and do not count as valid product negative controls.
Final
just format,just compile, fulljust testandjust lintpass on all 1,801 unchanged inputs: 4,935 unit passes across 264 suites, zero failures/errors/skips, and all eight fresh format/lint reports show zero findings. Complete logs, per-case XML/HTML and Gradle diagnostics were inspected; four existing cursor-close chains and three JVM notices match actual-base evidence. Earlier failed attempts and formatter corrections are retained. Two independent composition/security reviews plus the affected cancellation recheck are clean. The owner verified all 1,315 sealed evidence files. All 25 commits against the live parent verify locally and on GitHub (verified=true,reason=valid). Evidence:work/validation/android1689-vss-parent-final-audit-20261104/report.md. Exact published head1a6048a41a44e2e6fc34dda20a13ff073a112925(base4056d2c0036e5a4e51698e26b3d1615825dd77f5, treee0773ea784bf00b614ac419b699a394123c43416): hosted CI, lint and E2E passed: 4,935 unit tests, 256 Compose tests, 39 E2E passes plus 1 source-declared skip. Complete lint/Detekt reports contain 0 findings. Multi-address E2E's configured first 2 in-job attempts failed SDK system-image ZIP setup before tests; the third passed all 3 cases, with both earlier errors retained. Migration's 6 jobs were skipped. Complete raw and nested diagnostics were audited; recurring cursor, fixture, network and tooling warnings remain disclosed. Four metadata guard refusals each have later same-process metadata acknowledgement. This does not establish complete WALLET recovery, native quiescence, migration execution or dependency-security cleanliness. The owner verified all 937 sealed evidence files. Hosted audit:work/validation/hosted-android1689-1a604-20261104/report.md.Existing authenticated recovery evidence below retains its tested revisions. These native controls do not establish a new full-app identity-replacement journey, literal wallet wipe, running-LDK persistence quiescence or terminal complete-WALLET recovery. Migration remains disabled; no SDK, server or capability activation is added.
Authentication transport retry — November 3
Signed commit
e7fb0ac37beae6c3f8e9a58f0f5691ece312f03cadds the released VSS authentication request-send error to the existing retry classification. It accepts only typedAuthExceptioncarrying the exact HTTP(S) send-failure form (raw or the single native external-request wrapper). Authorization denial, invalid JWT, response/decode failures, cancellation, unrelated errors and malformed addresses remain permanent. No authentication or restore gate changes. Typed unknown errors still require the/putObjectspath.Final composed inputs:
just compile,just test(4,916 tests / 263 suites, zero failures or skips),just lint(six fresh structured reports, zero findings), unchanged final formatting, and two independent affected source/security reviews pass. The introduced formatter findings were corrected before the final run. Existing CloseGuard/JVM/Gradle notices were checked against the base and retained. All 1,799 validation inputs match. The complete 24-commit live-base range is locally and GitHub verified; base is43e0f3fe04705d397a81f493cf8e57239a0be73e. New-head CI, lint, and E2E passed on synthetic54d0d47c, whose tree exactly equals this head: 4,916 units, 256 Compose cases, six static reports with zero findings, and 39 E2E cases. One Settings case and six migration jobs were explicitly skipped. All seven E2E shards passed on their first workflow attempt; one Send action recovery remains disclosed. Complete logs, structured reports, 326,047 nested log lines and ten checksum/CRC-verified diagnostic archives were inspected; 836 sealed payloads were verified. Both APK digests are bound to producer/API records; all seven E2E consumers verified the E2E APK digest. APK archive bytes were not separately downloaded. Hosted report SHA256b6649d27a920f0b34d2763a4a070e7092cbeb2d15f62d9a58fc4770c453fbe93.Manual 8 transport portion: the ordinary fixture APK SHA256
090cbb7262a7d1036aa9e070df52e709afa3abc6501474d4cc84172c11a2ddbcbinds this exact two-file change. On unfunded Android emulator5554/user12, after cold launch with node reads unavailable, one UI Retry encountered the actual native auth request-send failure. It scheduled ten seconds of backoff. Reopening only auth routes led to a new capture 15.024 seconds later in the same process and one acknowledged write, without a second tap or unrelated edit. The existing BLOCKTANK ciphertext changed; its legacy stored version remained1 and the other15 cold rows stayed identical. There were no unknown writes. Original APK, nine routes and selected old-user state were restored. Warm LDK writes and three changed fixture-user cache/database files are disclosed. This is not a new typed readback or wallet restore; Case7B retains its separate native semantic evidence.Evidence:
work/validation/android1689-auth-transport-final-audit-20261103/report.json(SHA256fb02b1da4579aa388e601a9e927b8e3b856c3bb99dde51e864c2ef45e01de22e), its input-disposition addendum, and runtime auditwork/validation/android1689-auth-transport-runtime-audit-20261103/report.json(SHA2569b679e29554f306ef93fd205e39521d1033f1987f6ebc0bbc1808171da2a2419). Manual 8 denial portion also passed on the actual app: one explicit Retry received ten genuinely authenticated fixture refusals within the native client’s existing retry policy, but only one app capture. After permission was restored, a 25.240-second observation recorded no new auth request, capture, PUT or acknowledged revision. All sixteen cold rows stayed identical. The original APK, routes and selected old-user state were restored; two fixture-user cache/database changes are disclosed. The first attempt wrongly assumed one native callback and stopped at that assertion; it remains failed fixture evidence, not a product failure or a pass. The corrected cold-only run reused the preserved pending state without resetting the wallet. This bounded observation does not claim indefinite liveness. Denial audit SHA256a46e2f52abca285697de6f3133f85b5ee53fb6cc7a6df4c21093ee091ae98cf8. The fixed wire error text cannot distinguish a server emitting the identical typed error; it grants no authentication or write authority and uses the existing capped-delay retry. The paired iOS1123 classifier repair is published at11fb65d7; its scheduled-retry native evidence remains separate from this Android app execution and from iOS direct-UI Retry behavior.Cold-start ordinary Retry — November 3
Signed
8727da02549c3a885fbb48b500747b3401d3d649fixes the reported cold-start Retry hang. Ordinary categories now initialize their VSS client before upload even when the Lightning node has never started. A setup-owned timeout retains bounded retry; caller/reset cancellation still drains and propagates. Newly reachable cold captures read persisted Blocktank data without network refresh and refuse failed activity/receipt reads, rather than publishing incomplete snapshots.Final
just format,just compile,just testandjust lintpass on 1,797 matching inputs: 4,913 unit tests, 263 suites, zero failures/errors/skips and zero static findings. Complete structured diagnostics and both independent affected source reviews are sealed. Earlier controlled failures and baseline-attributed warnings remain retained. The full 23-commit live-parent range is verified locally and by GitHub (verified=true,reason=validfor every commit).Manual 6 passed in the actual app, using released Core RC11/Paykit RC2/VSS RC1/LDK rc78, a fresh unfunded Android user and verified ARM64 APK
f762d47c…beb0733. A real display-preference edit remained pending through process death. With auth unavailable and node startup failing, explicit Retry initialized VSS and returned an authentication failure after 10.276 seconds instead of hanging. Restoring authentication only, while node reads remained rejected, let the next explicit Retry receive a real VSS PUT acknowledgement and synchronize SETTINGS. Another cold restart retained the same settings bytes and synchronized revision. No node-start event occurred after the first cold launch. The original APK, routes and older users were restored and checked. This is a SETTINGS case with no prior uploaded SETTINGS object. The observed failure occurred at authentication for PUT after setup succeeded; setup-failure handling and read-refusal behavior have separate component coverage. It does not establish prior-object preservation, complete-WALLET availability, remote restoration, payments or the unverified portions of Manual 1–5.Manual 7A — prior ACTIVITY object and local read refusal passed in the actual app. On the same verified published inputs and unfunded user, one explicit Transaction Log Retry ran before successful node initialization. A temporary, reversible
closed_channelstable-name fault caused the actual Core SQLite read to fail before VSS setup. The required revision advanced from1793717566863to1793739130414; synced stayed1793717561910, and the operation returned idle. There was no VSS PUT. The independently authenticated existing ACTIVITY row retained version 1, all 192 ciphertext bytes (SHA25605f378372069fa546047cf3db96efedff6f88f83615cab4c6ab92801830001ef) and its store/key binding; all 15 scoped remote rows matched before, after and at restoration. Actual node reads were refused and no successful node setup/start/observer was observed in that cold process. The original table name, row fingerprints, column/index/foreign-key shape, APK, nine routes and older-user state were restored. SQLite file bytes and raw schema text changed during the rename round trip, so byte-identical database restoration is not claimed. Two earlier pre-fault stops remain recorded. The prior decrypted payload contains no activity records (124 bytes): Manual 7B now has separate nonempty BLOCKTANK evidence below. This result is not a terminal node-failure, lifetime-never-started, BLOCKTANK, wallet-restoration or payment claim. Complete private evidence was audited and all 22 sealed members verified:work/validation/android1689-prior-read-failure-runtime-c-20261103/report.json.Manual 7B — nonempty persisted BLOCKTANK capture, cold Retry and native readback passed at
8727da02. The normal app APK3da259fa…891adretains all 1,797 tracked product inputs and released pins, adding only the existing debug localhost Blocktank provider endpoint. A genuine Fee Settings fetch changed the public cached fee info; refused app uploads left a natural pending revision. After process death, a distinct cold process initialized Core while node reads remained refused and no node/backup-observer start was observed. One Connection Receipts Retry captured the retained cache and received one authenticated native PUT acknowledgement, advancing synced to required1793742411351. Exactly the previously absent BLOCKTANK row was inserted at version 1; every other cold-stage row stayed identical. A separate write-refusing native probe then authenticated as that same original owner, decrypted the exact acknowledged row and verified a strict V1 envelope with non-null typed info equal to the retained SQLite cache (1,200 plaintext bytes; orders/CJIT empty). All 16 remote rows stayed unchanged during readback. Both original APKs, nine routes, test registration and selected older-user hashes were restored. The warm fixture legitimately made 24 LDK writes changing five LDK rows and changed the test user's cache/activity/Blocktank database bytes; whole-wallet byte identity is not claimed. This is a first BLOCKTANK insertion, distinct from 7A's existing ACTIVITY preservation, and does not prove remote wallet restoration or payment recovery. Complete traces and readback were audited; the owner verified all 11 sealed report payloads and 41 external inputs. Evidence:work/validation/android1689-case-b-readback-c-audit-20261103/combined-case-b.json.Authentication transport follow-up: review4457469574 is addressed by the signed fix and evidence above. Manual 6's second explicit Retry and Manual 7B's successful transport remain separate historical results; neither was retroactively counted as autonomous outage recovery.
New-head hosted native CI is complete on
8727da02and the identical synthetic tree: CI passed 4,913 unit tests and 256 Compose tests; lint has zero findings across all six structured reports; E2E passed 39 cases on their first internal attempts, with one explicitly disabledsettings_08skip. Six migration jobs were skipped, not passed. Complete logs and all ten report archives were inspected and verified; cursor, native callback-fixture initialization, wallet/network and backup-guard warnings remain documented, without claiming harmlessness. The external review check is separate from native CI and human approval. Runtime evidence:work/validation/android1689-cold-start-runtime-20261103; full native diagnostics:work/validation/android1689-cold-start-diagnostics-20261103.The current-head Piotr review verifies the cold-start source repair and retains the terminal-WALLET concern. Its suggested local-read coverage now has the separate Manual 7A preservation and Manual 7B nonempty-cache results above, with their exact limits; the terminal-WALLET concern remains open. Historical HTTP 405 causation remains unverified. No repeated review request was sent for this just-completed review.
Prior validated parent composition — November 3
Signed additive merge
add2c04b684609b6ad74a30835864d816f0a79f6incorporates dependency #1686 at43e0f3fe04705d397a81f493cf8e57239a0be73e, including current master44bf50ec. The backup-observer patch retains the same added and removed lines after context shifts; the automatic merge preserves writer draining, observation ownership and the new restore-category result contract. Published history is preserved.Final
just format,just compile,just testandjust lintpassed on this actual composed tree: 4,905 unit tests across 263 suites, zero failures/errors/skips, and zero Detekt/Android Lint findings. All four phases share the same 1,797 frozen inputs. Complete logs, XML/HTML/SARIF, fresh structured static reports andignoreFailuresoutputs were inspected. Four cursor-close diagnostics remain recorded and trace to emitter sources identical to the actual parent; this is not a claim of runtime harmlessness. The independent affected-composition review is clean. All 22 live-parent range commits are locally and GitHub signature-verified.At
add2c04b, hosted native checks completed and were fully audited: 4,905 unit passes, 256 Compose passes, zero static findings and 39 unique eventual E2E passes, plus one disabled Settings case and six skipped migration jobs. The original Lightning-security attempt failed while downloading its system image, before tests. The single human-authorized retry (job 125932596605) ran both previously blocked cases successfully on its first in-job attempt, and the aggregate passed. The other 37 E2E passes and existing unit/Compose/static results retain their original execution credit; remapped job IDs are not new runs. All report digests/member CRCs and complete nested diagnostics are inspected, retaining the initial infrastructure failure and runtime warnings. Retry supplement:work/validation/android1688-1689-authorized-infra-retries-20261103/diagnostic-audit/STRUCTURED-REPORT.json(133 new sealed payloads); original full audit and failures remain preserved. Earlier hosted and authored/external runtime results below retain their original revision, artifact and fixture limits; this composition adds no new device or authenticated-journey claim. Evidence:work/validation/android1688-1689-master44bf-final-audit-20261103/STRUCTURED-REPORT.jsonandwork/validation/android1689-master44bf-composition-20261103.Retained scheduler acceptance evidence — November 2
Current review 5731374382 retains the earlier installed scheduler cases at
9a837a6. These are historical external executions, so the earlier blanket description that Manual 1–5 were unrun was too broad. The author has inspected the disclosed controls and inline log excerpts, and compared relevant source: 22/24 selected lifecycle/retry function slices and the VSS/cache/USDT files and dependency pins match439b5d0. Shared WALLET/METADATA serialization, allowance observation and restore/capture changes retain their separate later evidence.The original build/install binding and Manual 1–5 event/readback traces have not been independently verified; the checkboxes below remain open for those exact gaps. Manual 2's reported empty-wallet envelope does not alone establish the same-signed-operation reset variant. These results are not current-head author executions, complete WALLET capture or permission to use the reviewer's fixtures. Evidence:
work/validation/android1689-retained-acceptance-reconciliation-20261102/REPORT.md.Current-head hosted results — November 2
All hosted results for
439b5d0eea89a72ee6cdf1167515b95339a65c58are inspected: 4,857 unit passes, six static reports with zero findings, and 39 distinct eventual E2E passes. Oneonchain_2execution timed out waiting forActivity-3; its automatic second attempt passed. The failure image contains all three transactions across two date groups. Exact source counts date headers in the activity index, while the suite expects contiguous transaction indices; this supports a selector mismatch, but no captured semantics tree establishes the concrete runtime IDs. No wallet-data-loss or causal repair claim follows.The synthetic tree exactly matches the published tree and suite
611509ee. One disabled Settings case and eight cached-pass skip records are distinguished; six migration jobs did not execute. Complete logs, all nested streams, nine checksum/CRC-verified archives and failure media were inspected; the owner verified 885 sealed payloads. Four CloseGuard observations and runtime diagnostics remain recorded. No manual CI rerun, new authenticated backup/restore pass or installed-binary claim is implied. Evidence:work/validation/hosted-android1689-439b-20261102-current-a/REPORT.md.Final identity-recovery parent composition — November 1
Signed additive head
439b5d0eea89a72ee6cdf1167515b95339a65c58incorporates #1686/b6751e22956653c4f2a80112143880b47298e30dat tree820b8c27563810cebc079345e05ee6b680a5ea09. The parent's allowance off/on admission repair and failure-only routing diagnostics are included; this PR's backup-observer patch remains byte-for-byte intact. The affected composition review is clean and retains the two independent parent concurrency reviews.Final required compile, 4,857 tests / 260 suites, formatting and lint passed with zero failures/errors/skips/findings. All 1,781 frozen files match. Complete logs, XML/HTML/SARIF and fresh changed-unit lint analysis were inspected. Four aggregate lint reports were UP-TO-DATE with matching sealed hashes; they are not described as regenerated. Four CloseGuard events remain retained, including three present only in the full executor stderr. All 21 live-parent range commits verify locally and on GitHub. Current-head hosted results are recorded above; older failures and the unresolved parent routing-test cause remain preserved. No new authenticated backup/restore journey or installed-binary equivalence is inferred. Evidence:
work/validation/android1689-parent-b675-20261101/final-diagnostic-audit/STRUCTURED-INSPECTION.json.Hosted validation completed — November 1
At
2eb0c492439ebad8943457622ba67437d828d1f2, all seven E2E shards and the status job have completed: 39 first-attempt passes, with one disabled settings case. The earlier audited 4,856 unit tests and six zero-finding static reports remain tied to this exact head and parent1b1426fb.The sole audit combined prior immutable evidence with only the missing complete shard logs and checksum/CRC-verified report archives. The runtime metadata-backup refusal is retained: its exclusion guard matches the actual base, while worker/handler context differs, so no blanket pre-existing-runtime or eventual-backup-completion claim is made. Other diagnostics, npm advisories and skipped migration jobs remain explicit. This is not new authenticated recovery acceptance. Evidence:
work/validation/hosted-android1689-2eb0-20261101-tail-b/REPORT.md.Current parent composition — November 1, 21:48 UTC
Signed additive merge
2eb0c492439ebad8943457622ba67437d828d1f2incorporates live #1686 at1b1426fbebeccd616c29311fa5bf652089727a84; final treea5c0dd6e741a81d34a1736fce15c4f00f95e52f8. The owned child patch is unchanged. The incoming queued allowance-admission and pending-profile-setup corrections have one clean affected composition review, reusing the matching independent parent concurrency reviews.Required
just format,just compile,just testandjust lintpassed 4,856 tests /260 suites, zero failures/errors/skips, and zero findings across all six Detekt/Android Lint reports. All 1,782 tracked inputs match the composed tree. Complete logs and structured reports were inspected; four CloseGuard allocation observations and JVM notices remain recorded, and stale Gradle report copies are excluded. All 19 live-base commits verify locally and on GitHub (true/valid).Current-head hosted CI awaits complete inspection. No new authenticated app journey or causal repair of prior E2E failures is claimed. The terminal complete-WALLET limitation and separately scoped profile-removal gaps remain unchanged. Earlier acceptance and failures below retain their exact tested revisions. Evidence:
work/validation/android1689-parent1b14-review-20261101/andwork/validation/android1689-parent1b14-final-a/.Earlier parent composition — November 1
Signed additive merge
a3693b0c7061642a0b4305c07841a438a79046b9incorporates live #1686/78269d8a1cae1ac127be93a96fba16f7d68d1019; treec5943235d319bbf7171ebccca58fd10c0327b188. The backup-observer delta remains unchanged. The composition includes queued-offer admission and stable activation during same-identity contact refresh, with one clean affected review and independent parent concurrency reviews retained.Required final formatting, compile, full tests and lint passed 4,849 tests /260 suites, zero failures/errors/skips. All 1,781 frozen inputs match; complete logs and fresh Detekt/Android Lint reports contain zero findings. Three JVM notices and four verified unchanged-provider cursor warnings are retained; stale copied reports are excluded. All 18 live-base commits verify locally and on GitHub. New-head hosted CI is pending inspection. No new authenticated app runtime is claimed from these gates.
The earlier
9cfe0527external evidence and hosted recovery remain revision-specific. The first multi-address failure, internal retry and media-clock provenance limits are preserved; this composition is not their causal repair. The terminal-identity complete-WALLET availability finding stays open, with no partial/stale backup fallback. Evidence:work/validation/android1689-parent7826-final-a/inspection/andwork/validation/android1689-parent7826-20261101/publication/.Historical-head external evidence — October 30, 22:21 UTC
Automated review 5719299576 at
9cfe0527reports automatic METADATA recovery after durable WALLET exclusion and writer/reset draining with a disposable staging-VSS wallet. Its cold-start outage repeat completed after ordinary sign-out; later signed-in synchronization remains unverified after native Pubky lock errors. It also reproduces the existing terminal-identity complete-WALLET availability failure, which remains open. The owner verified the revision and read the report/control patch; raw reviewer logs, DataStore, binaries and media were not independently audited. These remain attributed external evidence, with no additional author acceptance or human approval inferred.Historical parent composition — 30 October 2026
Signed additive head
9cfe052761ebdf51a017cebc3b7a9ab173d953efincorporates the validated profile-session recovery branch atff5aa32f4da1ec33fb8be63e0b84597d463c82b1, including the current #1686 parent78a0ff7a. The GitHub base remainsfix/restore-identity-background; the existing stack and published history are preserved.WALLET and METADATA capture/upload share the wallet serialization boundary while retaining generation-owned observation, cancellation drains and restored public-address exclusions. A new observer regression proves that a WALLET acknowledgement automatically resumes previously blocked metadata exactly once, without a manual metadata retry. Omitting that rearm fails the direct expected-one/actual-zero assertion; restored final source passes.
Final
just compile,just test,just lintand unchangedjust formatpass on treee27b3e6112da04eda455031c70de9fe5709cf9f6: 4,839 fresh tests / 260 suites, zero failures/errors/skips, zero Detekt/Android Lint findings, all 1,778 frozen inputs matched. Complete logs and structured reports were inspected. Three JVM notices, four unchanged-provider CloseGuard chains and historical copied Gradle reports remain recorded; the compile reused matching outputs and is not a clean-rebuild claim. Failed helper, negative-control and intermediate formatting results are retained.Two independent affected source reviews are clean after closing the automatic-rearm coverage finding. All 15 commits against the live base verify locally and on GitHub (
verified=true,reason=valid). Current-head hosted CI passed 4,839 tests / 260 suites, zero failures/skips; lint has zero findings across all six uploaded reports. Complete logs, all 262 unit HTML pages and structured lint reports were inspected. E2E run 41104465012 reached 39 unique eventual passes and one unrun Settings case. Its original onboarding/backup/numberpad job failed Docker port binding before tests. One bounded job-only recovery used the original APK and exact suite611509ee, then passed all seven selected cases on its first test step. Separately, the multi-address case failed its firstSentactivity assertion and passed the workflow's internal retry; its failure, screenshot and all five encoded video frames remain preserved. This is not a first-attempt clean E2E result. The failed clip/runtime carry October 21 timestamps within the October 30 run; clock/history provenance and the assertion's cause remain unresolved.All executed job logs, nine checksum/CRC-verified small archives, their 307 members and complete runtime diagnostics were inspected; current CI merge tree equals this published head. A 60-second RN backup-list timeout and other retained runtime errors are not waived by test success. The hosted recovery downloader verified the original APK digest; independent local APK/native byte inspection remains unperformed after a transfer timeout and low-memory guard. No new full-app fault-journey acceptance is claimed. Earlier hosted passes below apply only to their recorded heads. These component controls do not complete manual cases 1–5 or the authenticated fault journeys. The existing terminal complete-WALLET availability concern remains open.
Historical parent composition — October 29
Signed additive merge
1d3ba149eef529f598ed9947d145bc9aa2bc9536incorporates current #1686 parent217b42ef889952a6c1f3a30b0c44a8b1001db4a5; final tree753c9b8b2edc93bd2f2ba2ee5b277cf4cac38c06. Observer drains, explicit retry while stopped, original ownership, cancellation and complete-backup gates remain intact. The source review caught a test-helper argument conflict; the named-argument correction preserves all assertions and both parent/child test sets.Final required compile, full tests, lint and format passed. 4,742 tests across 256 fresh suites passed with zero failures/errors/skips; all 1,760 regular inputs and six symlinks match. Complete logs, XML/SARIF and ignoreFailures configuration were inspected: zero compiler/lint/format findings. Four cursor-close warnings in test output and three JVM notices remain disclosed with actual-parent source comparisons and no runtime-causality claim. Full 113-case BackupRepo coverage passes. Existing authenticated restoration/outage and terminal complete-WALLET acceptance gaps remain distinct.
The complete nine-commit live-base range is locally and GitHub verified (
verified=true,reason=valid). Ready status is retained (draft=false). Current-head hosted CI passed 4,742 tests in 256 suites, and lint reports zero Android Lint/Detekt findings. E2E reached 39 unique eventual passes and one true settings skip. Settings attempt 1 timed out after 30 seconds waiting for ElectrumErrorToast; its automatic second attempt passed. The original exit 1/failure annotation remain retained; the cause is unproven, and failure-only media was not uploaded after eventual success. This is not a first-attempt clean pass or the authored fault journey. All 14 complete executed logs, 18 annotations, complete uploaded reports and four SHA-256/CRC-verified archives were inspected; the synthetic merge tree equals this head and its actual parent. Four unchanged-provider cursor stacks, three JVM notices and absent uploaded Gradle problem-detail reports remain disclosed. Migration was skipped, not passed; no rerun was requested. Historical execution evidence below remains revision-scoped.Historical scheduler repair — 29 October 2026
Signed head
9a837a6e7b9286d4dca24d57c451929131285b27includes the additive parent mergee80ca3f95from #1686 at9ce430274. A permanent failure suppresses only the revision that failed: a newer committed recovery revision remains eligible for observation after its caller cancels. Background coalescing now waits before taking the category mutex, so an explicit Core callback does not wait behind idle debounce. It still waits for an active upload and must obtain acknowledgement from its own immutable snapshot write.Both controlled regressions failed before the repair and pass afterward. Final
just format,just compile,just testandjust lintpassed on the combined tree: 4,714 fresh unit tests / 256 suites, including all 112 BackupRepoTest cases, with zero failures/errors/skips and zero Detekt/Android Lint findings. All 1,760 frozen inputs match. The complete 9,692-line test log, all XML/HTML/SARIF and embedded Gradle diagnostics were inspected; copied older reports were separated from fresh results. Three JVM notices, four verified pre-existing provider cursor warnings and the unchanged Detekt plugin deprecation remain disclosed. The canonical compile used matching up-to-date outputs; this is not a clean-rebuild claim.Two independent scoped correctness/concurrency reviews are clean. All eight live-base commits verify locally and on GitHub (
verified=true,reason=valid); published history is preserved. Current-head hosted CI passed 4,714 tests in 256 suites, and lint reports zero findings. E2E completed 39 distinct passes and one explicit settings skip: 38 cases passed first attempt; Lightning first failed with an invoice expired on October 22, then passed automatic attempt 2. Security's second-attempt marker skip follows its actual first-attempt pass. All 11 complete E2E logs (7,041 lines), every attempt and annotation were inspected; the earlier failure and unproved cause remain retained. The synthetic checkout tree equals this head and tests the current parent. CI/lint report archives were SHA-256/CRC verified; all seven emulator consumers checked the published APK archive digest, without a local installed-binary claim. Earlier failure media was not uploaded after eventual success. Migration was skipped, not passed. No workflow rerun was requested. Earlier hosted results below remain revision-scoped. These service-callback tests use mocked VSS and do not establish the authored full-app fault cases, process-death payment submission or an authenticated native journey. The current iOS twin already keeps idle debounce outside its USDT writer; no mechanical opposite-platform patch is indicated by the narrow source comparison.Prior parent composition — 28 October 2026
Signed additive merge
5fca5cd39df4fcf072eacb36bd63c758a55a0f5dincorporates parent #1686 at89e2702e, preserving the existing stack. The final tree2edb93a2passed 4,638 unit tests in 254 suites, zero failures/errors/skips, plus requiredjust format,just compileandjust lint. Complete logs, all XML/HTML/SARIF and embedded Gradle reports were inspected; Detekt and Android Lint have zero findings. All 1,753 frozen file inputs and the directory link match. Three JVM notices, four unchanged-provider CloseGuard stacks, the inherited manifest notice and Detekt deprecation remain disclosed against exact parent source/configuration; no fresh parent runtime baseline or warning-free claim is made.All 605 production feature patch operations are unchanged. The sole conflict removes two obsolete refund-pointer tests with the parent's Blocktank sunset; no retry, snapshot, reset or revision assertion was dropped. One independent affected-composition review is clean, retaining prior matching lifecycle/explicit-retry review coverage. These full unit gates do not establish the authored authenticated lifecycle/endpoint fault journeys or resolve terminal full-WALLET availability.
All 6 live-base commits verify locally and on GitHub (
verified=true,reason=valid), with exact sets matching. This PR remains ready. Current-head hosted CI passed 4,638 unit tests in 254 classes with zero failures/skips, and lint has zero Detekt/Android Lint findings. E2E reached 39 distinct passes and one explicit settings skip: boost_2, widgets_1 and widgets_3 first failed their expected-text, WidgetSave and PriceWidget assertions, then passed automatic attempt 2. Earlier failures and process annotations are retained; their causes remain unproven. This is not an all-first-attempt pass or the authored authenticated fault journey. The complete 14 executed job logs, all unit/lint reports and four SHA-256/CRC-verified archives were inspected; the executed merge tree equals this head and its tested parent matches the live stack. Failure-only media was not uploaded after eventual success. No workflow rerun was requested. Migration was skipped, not passed.Twin: synonymdev/bitkit-ios#1123
Dependency: #1686
This PR drains interrupted backup work before resetting its clients and preserves pending backups across observation restart.
Description
Out of Scope
Design
N/A — no UI changes.
Preview
N/A — background lifecycle repair.
QA Notes
Setup
Use the committed dev Debug dependencies: Core 0.8.0-rc11, Paykit 0.1.1-rc2, VSS 0.5.26-rc1 and LDK 0.7.0-rc.78. Automated tests use synthetic data, a real local DataStore, the SDK operation barrier and fake VSS transport; no funds or device wallets are changed. Manual cases require a dedicated disposable fault fixture with held setup, capture, upload and cache-write boundaries as described in
journeys/backup-restore/wallet-snapshot-consistency.md. Those app-level controls are not provisioned in the standard journey environment. Do not use live funds, reset personal wallets or reuse another worker's device.For Manual 6, use an isolated dev/E2E regtest build with a disposable local LNURL-auth/VSS backend. Set
E2E=true,E2E_BACKEND=local,E2E_VSS_SERVER_URL=http://localhost:18717/vssandE2E_LNURL_AUTH_SERVER_URL=http://localhost:18715/auth; reverse only those device ports to a dedicated fault proxy forwarding to the fixture. Keep authorization headers and native encryption intact. First reject authGET /authbefore forwarding and nodePOST /vss/getObject/listKeyVersions; then allow auth andputObjectswhile continuing to reject node reads. Use Settings → Default Unit for the edit and Settings → Security → Data Backups for Retry. Retain proxy outcomes, app lifecycle/backup logs, installed APK hash and nonsecret backup-status readbacks. No wallet reset, seed capture or funds are needed. Restore prior device routes and APK after testing.Journeys
N/A — not drivable; see Manual Tests.
Manual Tests
1. Hold backup setup or cancellation cleanup → stop/restart observation → edit settings while restart waits for old cleanup, including another category’s retry becoming due → no setup/upload before drain; latest changes resume without another edit — historical external Test 1 is reported; original event/readback and build/install binding remain to be verified.
2. Hold manual or USDT persistence → request fixture reset, including cancellation of its caller → both clients wait for old work; canceled persistence is not acknowledged and retry retains the same signed operation — historical external Test 2 covers an empty-wallet envelope; build/trace binding and the same-signed-operation reset variant remain unverified.
3. Keep wipe admission closed after reset → request manual/full/USDT backup → no new upload or cache write; after release, explicit USDT works with observation stopped — historical external Test 3 reports backup wipe-admission controls; its no-write/ack trace and build binding remain unverified.
4. Change required data during a full backup, hold its required-status write, or request it during stopped-job cleanup → release the gate and restart → the newer work uploads without another mutation — historical external Test 4 reports SETTINGS follow-up; held-write/revision/readback and build binding remain unverified.
5. With observation stopped, fail an explicit category or USDT backup temporarily, restore transport, and verify bounded backup retry without another edit; keep Core acknowledgement withheld until its own callback succeeds. Also cancel a newer callback while an older upload is held: the newer revision stays pending and the old upload cannot satisfy it — historical external Tests 5a/5b and later callback controls are reported; their original trace and build binding remain unverified.
6. On an unfunded disposable wallet, change a display preference and stop the app before backup debounce completes. Cold-start with VSS authentication unavailable and node initialization blocked; SETTINGS Retry must return failure with the revision pending. Restore authentication while continuing to reject node reads, tap Retry, then cold-restart again: SETTINGS remains synchronized without node startup. Executed on the exact candidate APK/source above; this does not cover other categories or remote restore.
7A. With an existing remote ACTIVITY object, cold-start before node success and refuse the local Core activity read. One Transaction Log Retry must return idle with required pending and preserve the acknowledged remote object. Executed at
8727da02; the prior object is empty, and reversible schema fault restoration is logical rather than byte-identical.7B. Change nonempty persisted Blocktank info through the real provider while app uploads are refused, then cold-restart with node reads blocked. One Connection Receipts Retry must acknowledge the retained cache; independently decrypt/read that exact row through native VSS and compare typed info. Executed at
8727da02; first BLOCKTANK insertion, empty orders/CJIT, no full-wallet restore claim.8. Cold-start an unfunded test wallet with ordinary backup pending and auth request transport unavailable. Tap Retry once, restore auth while node reads remain blocked, and verify bounded automatic retry acknowledges the original pending category without a second tap or unrelated edit. Genuine authorization denial stays pending without automatic retry. Both actual-app portions passed on the exact e7fb0ac inputs, with the bounded 25.240-second denial observation, native internal retries and preserved first fixture assertion failure detailed above. Earlier Manual 6 is not its execution.
Automated Checks
BackupRepoTest.kt— held setup and upload cleanup, reset cancellation, cache finalization, concurrent lifecycle calls with real DataStore, actual SDK wipe-lock ordering, full-backup follow-up, stopped-observer USDT persistence and actual service acknowledgement/retry record identity.Evidence
Controlled baseline reproduced late LDK setup after reset. Further fail-before tests reproduced lost full-backup follow-up, premature manual reset, fresh backup admission during wipe, a full request lost behind canceled cleanup, and consumed settings changes lost during stop/reset. The final focused suite has 93 cases, including 14 added lifecycle, admission and persistence regressions.
Final commit
a8f0810769b5c29c01c6f7c1f02e3988b048c7db, based on #1686 (ac2a7c668):just compile,just testandjust lintpassed: 4,639 tests across 254 suites, zero failures/errors/skips; fresh JUnit timestamps verified. Fulljust formatpassed. All Detekt and Android Lint SARIF/XML reports contain zero findings, with no compiler warnings; the JVM bootstrap-classpath notice and Gradle deprecation match recorded baseline diagnostics. Two consecutive final independent read-only reviews found no actionable correctness or quality issues.Follow-up verification on October 25 (signed head
e585fccf5): the settings-during-restart regression fails ona8f08107; a separate temporary WALLET retry with held WIDGETS cleanup fails without its admission guard. Both repaired cases pass in the 95-test backup suite. Finaljust compile,just test,just lintandjust formatpassed: 4,641 tests / 254 suites, zero failures/errors/skips, and all Detekt/Android Lint reports contain zero findings. Complete logs and reports inspected; structured Gradle diagnostics match the earlier baseline. Two consecutive independent final reviews were clean. Both commits in the actual-base PR range are locally verified and GitHub reportsverified=true,reason=valid. These controlled component cases extend manual cases 1 and 4; no full fault-injection journey pass is claimed.Prior published head
a8f08107also passed hosted CI (4,639 units) and all seven existing E2E shards (39 cases passed, one settings case skipped). Complete logs and checksum/CRC-verified uploaded lint artifact12583364581contain zero SARIF/XML findings. Those hosted results apply to that head, not the follow-up.Prior signed head
e585fccf5passed hosted CI40158027061(4,641 tests) and lint40158027049. Complete logs and checksum/CRC-verified artifact12585438925were inspected; all three SARIF/XML reports contain zero findings. Hosted E2E40158027317 subsequently completed all seven shards with39 passing cases. The backup case first failed waiting for the widget “Yes, Delete” confirmation, then passed on the workflow’s automatic retry. Complete logs were inspected; the initial widget-confirmation failure’s cause is still unproven, so this is not claimed as a clean first-attempt run. No additional workflow rerun was requested.Latest parent composition (October 26), signed head
c2554581e2b5e2c8e68802a577a76a56b291ae4b: additive merge of #1686 at0fd5e424, without rewriting published history. Both parent malformed connection-correction tests and the lifecycle ownership/USDT independence behavior are preserved. Finaljust compile,just test,just lint, andjust formatpassed: 4,681 tests / 255 suites, zero failures/errors/skips, 97 backup cases, and zero Detekt/Android Lint findings. Complete logs and structured reports were inspected; JVM, cursor and Detekt-plugin diagnostics match verified earlier parent evidence. All 1,765 frozen inputs match. Two consecutive independent final composition reviews were clean; all three current-base commits are locally signed and GitHub true/valid. Hosted composition checks passed 4,681 units, lint with zero findings, and 39 first-attempt E2E cases with one settings skip; complete logs and checksum/CRC-verified reports were inspected. These results apply to c255458, not the later transport commit, and are not an authenticated fault-journey pass.Latest transport follow-up, signed
a102e0ede565e60aaf7e033f3ad66a870739066b: the released VSS FFI represents request-send failures as UnknownException. Two controlled regressions fail before the fix; the narrow backup-only compatibility classifier and observed-settings retry then pass. Final4,683 tests/255suites, including99backup cases, pass with zero failures/errors/skips. Required format, compile, full Detekt and Android Lint pass with zero findings in all three fresh SARIF/XML reports. All1,760 frozen source inputs match. Complete9,629-line test log and structured reports were inspected; three JVM notices, the full cursor-allocation stack multiset and Detekt plugin diagnostics match verified parent evidence. Initial formatting corrected three introduced ordering/wrap findings before final gates. Two consecutive independent final reviews are clean; all four live-base commits are locally signed and GitHub true/valid. Hosted checks on a102e0e passed 4,683 units and lint with zero findings. Existing E2E cases reached 39 unique passes and one never-run settings skip after five failed executions: LNURL/Transfer failed two attempts before the third passed, and multiaddress required a second attempt. Complete logs and checksum/CRC-verified artifacts were inspected. These are eventual prior-head passes, not first-attempt or new-head evidence; the timeout causes remain unproven.This classifier is restricted to the typed native UnknownException with its anchored request-send envelope and valid HTTP(S) host/putObjects path. It does not change generic SDK mutation retry, cancellation or permanent unknown errors. Tests inject the actual native error representation into the repository; this is not a new authenticated endpoint-outage journey.
Latest explicit-retry follow-up, signed
49e9a99d35f72685f3187b9585fb2aaa3d64a904: manual/full/USDT backup retries retain their own ownership after observers stop. Supplied recovery snapshots reserve their exact required revision before a cancellable category wait, so canceled queued work remains pending and an older upload cannot acknowledge a newer snapshot. Superseded retry timers cannot bypass a newer failure’s backoff. Thirteen new regression cases include controlled before failures for stopped observation, successor revisions, stale timers and queued cancellation. The final suite passed 4,696 tests / 255 suites (112 backup cases), zero failures/errors/skips. Final format, compile, full Detekt and Android Lint pass with zero findings in all three structured reports. All 1,760 frozen hashes match; the complete 9,648-line test log, all XML/SARIF and retained Gradle diagnostics were inspected. Introduced formatting findings were corrected before the final unchanged gates; JVM/cursor/deprecation diagnostics match verified parent evidence. Two consecutive independent final reviews (rounds 2 and 3 after repairing the round-1 queued-callback finding) are clean. All five actual-live-base commits verify locally and on GitHub as true/valid. Current-head hosted CI40400088998 passed 4,696 tests/255 classes with zero failures/skips; lint40400089110 has zero findings in all structured reports. E2E40400089623 reached 39 unique passes and one never-run settings skip after three failed executions: RGS settings, LNURL and transfer each passed their second internal attempt. Complete 18,712 lines across 15 job logs and four SHA256/CRC-verified archives were inspected. The merge tree equals this head; JVM/cursor/deprecation diagnostics match verified parent evidence. The timeout causes remain unproven and failure screenshots were not uploaded after eventual success; no rerun was requested. Manual case 5 and complete authenticated fault journeys remain unrun; these controlled component tests use the real service callback with mocked transport and do not prove autonomous payment submission or process-death retry.Gaps
Explicit backup retry now has ownership independent of observation. Background backup success does not acknowledge an earlier failed Core callback or submit a payment; manual USDT Retry and foreground recovery must still resume and acknowledge the original signed operation. Cancellation retains required data without turning every canceled or permanent failure into a fresh automatic retry.
The new controls are component tests. They do not execute a complete app UI/Paykit/deployed-contract failure journey, and the VSS transport in these tests is mocked. Existing released-native signing/encrypted recovery evidence remains scoped to its recorded app revisions. No Android crash or historical contact/acquisition cause is claimed. The full-WALLET availability and migration engineering remain separate. Updated-app-only new-format restore is approved, but independent-only backups are not activated.
Models used
gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh)gpt-6-astra(reasoning:xhigh);gpt-6.1-sol(reasoning:high)