Repository navigation
fix: preserve pending backups across restart - #1689
ben-kaufman wants to merge 9 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.
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — both existing backup failures still reproduce at 5fca5cd.
Findings: No new findings in the parent-integration delta. I added current-head device evidence to these existing reports:
- MEDIUM — explicit recovery waits behind idle debounce,
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:543. A real signed-send callback waited 4.851 seconds before the scheduled recovery PUT, then made its own identical PUT and acknowledged after 5.237 seconds. The category mutex covers the five-second debounce. Existing finding. - LOW — cancelled reservation retains an obsolete failure marker,
app/src/main/java/to/bitkit/repositories/BackupRepo.kt:649. After permanent failure and cancellation of a newer committed reservation, observation still refuses recovery upload with healthy transport. An explicit callback heals it. Existing finding.
QA for 5fca5cd: Android 15 / API 35, arm64. I built the unmodified head and the Debug fault builds below. Current device cases use real DataStore and LNURL-authenticated encrypted staging VSS; signed-payment cases use the committed bitkit-core 0.8.0-rc11 binding and a disposable local Arbitrum fork with test funds. Timing, payload, acknowledgement and submission assertions were checked in app and fixture logs. The controls stage scheduling or hold operations; native wallet and VSS decisions are unchanged.
Test 1: pending changes and due retry across restart ✅ Settings and a due retry waited for cleanup and uploaded after release. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions, at 49e9a99.
case1-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 2: reset drains manual and recovery persistence ✅ Reset drained held writes despite caller cancellation; recovery cancellation withheld acknowledgement. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions.
case2-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 3: wipe closes explicit admission ✅ Wipe admission prevented uploads and status writes; explicit recovery worked afterward with observation stopped. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions.
case3-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 4: full-backup follow-up and held status writes ✅ New revisions and requests behind cleanup resumed without another edit; the scheduler was driven through SETTINGS. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions.
case4-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 5a: explicit retry with observation stopped ✅ Temporary native failures retried and recovered without observation; background success did not acknowledge Core. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions.
case5a-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 5b: cancelled queued recovery revision ✅ An older upload could not synchronize or acknowledge the cancelled newer callback. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions.
case5b-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 7: stopped-observer backoff through the five-minute cap ✅ Retry delays reached the five-minute cap, and recovery respected the scheduled delay. Ran on 49e9a99; unchanged paths checked before carrying.
Earlier execution and assertions.
case7-2x.2x.mp4
Original 49e9a99 recording, 2×.
Test 6: cancelled reservation after a permanent failure ❌ Real native InvalidData followed by cancellation after the newer DataStore reservation still leaves observation making no recovery PUT with healthy transport; explicit retry heals it.
The native permanent error was InvalidData. After the newer reservation committed, I cancelled its caller, restored transport and started observation. No recovery PUT occurred before the healing explicit callback. This confirms observation-owned liveness, without a money-loss claim.
case6-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 8: explicit recovery callback during scheduled debounce ❌ The real callback took 4219 ms to acknowledge: 3049 ms elapsed before the scheduled PUT, followed by its own separate PUT. Scheduled debounce held the category mutex for 5066 ms.
Scheduled work was staged through the real status store; the callback and uploads were unchanged. This isolates the overlap without claiming a complete authenticated contact-request UI journey.
case8-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 9: native signed operation after rejected backup ✅ A failed callback left one native signed operation pending and zero submissions. Background backup success did not acknowledge Core. Native refresh then acknowledged the identical snapshot and signed-plan bytes before exactly one submission.
The rejected callback left one signed operation pending and zero fixture submissions. Automatic upload did not acknowledge Core. Native refresh then acknowledged the identical recovery snapshot and signed plan before one submission. This extends manual cases 2 and 5 beyond the earlier empty-wallet evidence.
case9-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 10: native signed send behind scheduled recovery ❌ The real native signed-send callback acknowledged after 5237 ms. Its first recovery PUT began 4851 ms after callback entry, at the end of scheduled debounce; a separate identical recovery PUT followed before send returned.
Scheduled work was staged through the real status store; the callback and uploads were unchanged. This isolates the overlap without claiming a complete authenticated contact-request UI journey.
case10-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Check 1: unmodified-head build and regressions ✅ assembleDevDebug, BackupRepoTest, WalletConnectionRecoveryTest and WalletViewModelTest passed. JUnit reports confirm execution without failures or skips. The obsolete refund-pointer tests match the Blocktank sunset; restored accepted funding now uses ordinary local follow-up. The earlier Proceed-admission and duplicate-startup-toast defects are repaired in the composed parent, checked through source and fresh regressions rather than another startup device reproduction.
Check 2: delta and carried evidence ✅ Contiguous 49e9a99..5fca5cd, including parent composition and reached backup, restore, wipe, startup and acknowledgement callers. Lifecycle, retry, reservation and recovery-upload bodies match 49e9a99 byte for byte. Seven earlier passing device cases carry at their original head; four cases ran here. No planned case remains unrun. The broad imported parent/master features did not receive a new independent device sweep.
The parent review at 89e2702 still applies to inherited terminal full-WALLET availability and optional-profile retry. This child does not contain the parent's later reconnect repair. Complete WALLET capture/restore, process-death signed recovery and authenticated Paykit request negotiation were not newly driven here; the signed-operation checks used real native send and recovery through Debug controls.
Twin: bitkit-ios#1123 fix: serialize backup observation lifecycle, inspected at 566b2ff, matches the drain, pending-change and independent recovery intent. Its debounce remains outside the serialized USDT writer, so the Android callback delay does not match that path.
Local Debug controls — not pushed
Tests 6 and 8 used these holds and logging over the reviewed head. The shell-only control surface runs on a disposable installation; the endpoint override routes encrypted VSS through the transport control below.
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 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 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 a466b8837..5d103488b 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 18b66ecaa..b47fd26b0 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -540,6 +540,7 @@ class BackupRepo @Inject constructor(
runningBackups += category
try {
updateBackupStatus(category, expectedGeneration) { it.copy(running = true) }
+ to.bitkit.testhook.BackupFault.event("DEBOUNCE $category") // test hook
delay(BACKUP_DEBOUNCE)
if (category == BackupCategory.WALLET) {
paykitSdkService.isPaymentSubmissionActive.first { !it }
@@ -832,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/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt
index ef299b029..8590851b2 100644
--- a/app/src/main/java/to/bitkit/services/UsdtService.kt
+++ b/app/src/main/java/to/bitkit/services/UsdtService.kt
@@ -38,6 +38,8 @@ 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
+ to.bitkit.testhook.BackupFault.hold("callback") // test hook
backupRepo.get().persistUsdtRecoveryBackup(snapshot).getOrElse {
Logger.error(
"Failed to persist USDT recovery backup with '${it.javaClass.simpleName}'",
@@ -46,6 +48,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/debug/java/to/bitkit/dev/BackupFaultProvider.kt b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
new file mode 100644
index 0000000..7fe4ce4
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/BackupFaultProvider.kt
@@ -0,0 +1,120 @@
+// 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 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)
+ 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" }
+ "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
+ "history" -> { val history = deps.usdt().wallet(requiresNetwork = false).history(); val text = history.joinToString { "${it.id}:${it.status}" }; BackupFault.event("HISTORY $text"); text } // test hook
+ "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\n5fca5cd + local hold/log hooks\nReal DataStore and native encrypted VSS\nNative empty-wallet recovery; 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/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) }
+}
Tests 9 and 10 added native refresh/snapshot inspection, scheduled-send staging and a test-funds caption. Their local chain used bitkit-docker ce003b0, usdt-fixture, fork block 513660679 and gateway service 299ecba. The app build pointed USDT_RPC_URL at http://127.0.0.1:23453/v1/usdt/chain-rpc and USDT_BUNDLER_URL at http://127.0.0.1:23453/v1/usdt/rpc; the app itself signed both operations.
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
@@ -79,6 +79,13 @@
"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
"reset" -> { asyncTask("reset") { deps.backup().reset() }; "started" }
"cancel" -> { tasks[arg]?.cancel(); BackupFault.event("CANCEL $arg"); "ok" }
@@ -106,7 +113,7 @@
private lateinit var text: TextView
private val refresh = object : Runnable {
override fun run() {
- val output = "Backup lifecycle fault fixture\n5fca5cd + local hold/log hooks\nReal DataStore and native encrypted VSS\nNative empty-wallet recovery; no funds\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
+ val output = "Backup lifecycle fault fixture\n5fca5cd + local hold/log hooks\nReal DataStore and native encrypted VSS\nDisposable local fork; test funds only\n\n" + BackupFault.events.takeLast(25).joinToString("\n")
if (text.text.toString() != output) text.text = output // test hook
handler.postDelayed(this, 250)
}
Transport control: forwards encrypted VSS to staging; it can close PUT connections or return a permanent native-protocol error.
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()Reviewed by gpt-6.1-sol-high
There was a problem hiding this comment.
Verdict: ✅ Approve — both scheduler findings are fixed at 9a837a6.
Findings: No new delta findings. Fresh device checks confirm the cancelled-reservation repair and the explicit-callback debounce repair.
QA for 9a837a6: Android 15 / API 35, arm64. I built the unmodified head, then drove its Debug fault build with real DataStore and LNURL-authenticated encrypted staging VSS. Signed-operation cases used the committed native binding and test funds on a disposable Arbitrum fork. Videos show the diagnostic control surface at 2×; timings, revisions, payload digests, acknowledgements and submission counts were checked in app and fixture logs.
Test 1: pending changes and due retry across restart ✅ Logs confirmed that replacement setup, settings and a due retry waited for cleanup, then uploaded the edit without another mutation.
Held WIDGETS cancellation cleanup, requested restart, edited synchronized settings to EUR and let a BLOCKTANK retry become due. Decrypted SETTINGS readback contained EUR and matched its upload digest.
case1-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 2: reset drains manual and recovery persistence ✅ Logs confirmed that cancelled reset callers still drained both held writes, and cancelled recovery received no acknowledgement.
Both clients reset only after the held manual or recovery write returned. Explicit recovery retry with observation stopped acknowledged once and retained the same native empty-wallet envelope and content-addressed key.
case2-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 3: wipe closes explicit admission ✅ Logs confirmed no new upload or status change during wipe, followed by successful explicit recovery with observation stopped.
Manual, full and recovery requests were refused while wipe admission remained closed after reset. Releasing admission allowed the normal callback to persist.
case3-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 4: full-backup follow-up and held status writes ✅ Logs confirmed successor uploads, a retained required-status write and a full request behind cleanup; decrypted readback contained CHF.
Exercised the shared full-backup scheduler through SETTINGS. A change during upload triggered a successor; a consumed edit survived a held cache write across stop/restart; a full request queued during cancelled cleanup resumed without observation. This does not establish complete WALLET capture.
case4-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 5a: explicit retry with observation stopped ✅ Logs confirmed native connection-failure retries with observation stopped; automatic success did not acknowledge the failed callback.
Manual SETTINGS and native empty-wallet recovery each failed twice, used 10-second then 20-second backoff plus normal scheduler debounce, and recovered without another edit. Only a successful explicit callback retry acknowledged Core. All recovery attempts retained one payload digest and content-addressed key.
case5a-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 5b: cancelled queued recovery revision ✅ Log comparisons confirmed that older completion retained the newer cancelled reservation and could not acknowledge its callback.
The newer required revision committed before the cancellable category wait. Immediately after older completion, synced still referred to the old revision; a later explicit callback retry wrote and acknowledged its own revision. Background follow-up remained independent of callback acknowledgement.
case5b-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 6: cancelled reservation after a permanent failure ✅ Logs confirmed that observation now uploads a newer cancelled reservation after permanent failure, without acknowledging the cancelled callback.
A real native InvalidData response permanently failed the old revision. After a newer DataStore reservation committed, its caller was cancelled. Healthy observation uploaded the newer work once; failed, cancelled and background work left the acknowledgement counter unchanged. An explicit retry then acknowledged once.
case6-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 8: explicit recovery callback during scheduled debounce ✅ App timestamps measured a 483 ms callback acknowledgement during idle debounce, with one recovery upload and no delayed duplicate.
Scheduled work was staged in the real status store. The callback entered 1,978 ms into the five-second debounce and began its own upload 31 ms later. Waiting past the scheduled delay produced no second recovery PUT.
case8-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 9: native signed operation after rejected backup ✅ App and fixture logs confirmed identical signed recovery bytes across rejection and native retry, with zero submissions before acknowledgement and one afterward.
A rejected backup left one real signed operation pending. Automatic backup success left Core unacknowledged. Native refresh invoked its own callback, acknowledged the identical recovery snapshot and signed-plan digest, and then submitted exactly once. This used native send/recovery methods through Debug controls, rather than contact-request UI negotiation.
case9-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 10: native signed send behind scheduled recovery ✅ App timestamps measured a 484 ms signed-send callback acknowledgement during idle debounce; fixture logs confirmed one new submission.
The callback entered 406 ms after scheduled debounce began and started its own recovery PUT 25 ms later. Exactly one recovery upload occurred, acknowledgement preceded send return, and the delayed job added no duplicate. Scheduling was staged in the real status store; native signing, persistence and submission were unchanged.
case10-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Test 11: explicit callback waits for active upload ✅ Logs confirmed that an explicit callback waits for an active older upload, then performs and acknowledges its own write.
While the older PUT was held, the newer callback reserved its revision but started no second PUT and received no acknowledgement. Releasing the older upload allowed the newer immutable upload to complete and acknowledge once.
case11-2x.2x.mp4
Controlled app execution, 2×; nonvisual assertions checked in logs.
Check 1: unmodified-head build and committed regressions ✅ The Debug build and focused backup/recovery suites passed.
assembleDevDebug, BackupRepoTest and WalletConnectionRecoveryTest executed successfully. I inspected their complete build log and fresh JUnit reports, including both new regressions and the existing reset, cancellation, stale-timer and revision-ordering cases. This was focused verification, rather than a fresh full-suite or lint run.
Check 2: inherited parent and twin ✅ Source comparison and the current-parent report establish the composition boundaries.
The incorporated parent is 9ce4302. Its current-head review confirms the missing-profile retry repair and still reproduces the separate terminal full-WALLET availability HIGH. This scheduler approval does not claim that inherited issue is fixed.
Twin: bitkit-ios#1123 fix: serialize backup observation lifecycle, inspected at 566b2ff, matches the backup lifecycle and debounce-before-writer contract. Its latest profile-retry parent composition has not yet been incorporated.
Check 3: earlier capped-backoff evidence ✅ The backoff calculation and timer bodies remain unchanged.
The 49e9a99 device report measured actual 10, 20, 40, 80, 160 and 300-second retry delays. I rely on that report only for the unchanged calculation and cap. Tests 1 and 5a above freshly exercised the changed scheduler admission; no new eleven-minute cap run is claimed.
Reviewed the contiguous 5fca5cd..9a837a6 delta, the two open findings and reached parent composition. All planned controlled device cases ran. Complete WALLET capture/restore, process-death payment retry, authenticated contact-request UI negotiation and the imported confirmation/background-admission UI flows were not newly driven: these cases isolate backup scheduling and native callback ownership.
Local Debug controls — not pushed
The controls hold setup, uploads or cache writes and log results; the repaired scheduling decisions remain unchanged. The shell-only surface invokes real repositories on a disposable installation. The transport control forwards encrypted VSS to staging, or closes PUT connections / returns protocol errors to exercise native error handling. Signed cases use bitkit-docker ce003b0, fork block 513660679, with the app itself signing each operation.
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 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 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 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 b7a144143..075cd38aa 100644
--- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
+++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt
@@ -537,6 +537,7 @@ class BackupRepo @Inject constructor(
try {
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
@@ -832,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/services/UsdtService.kt b/app/src/main/java/to/bitkit/services/UsdtService.kt
index ef299b029..8590851b2 100644
--- a/app/src/main/java/to/bitkit/services/UsdtService.kt
+++ b/app/src/main/java/to/bitkit/services/UsdtService.kt
@@ -38,6 +38,8 @@ 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
+ to.bitkit.testhook.BackupFault.hold("callback") // test hook
backupRepo.get().persistUsdtRecoveryBackup(snapshot).getOrElse {
Logger.error(
"Failed to persist USDT recovery backup with '${it.javaClass.simpleName}'",
@@ -46,6 +48,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/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,127 @@
+// 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 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)
+ 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" }
+ "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
+ "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\n9a837a6 + local hold/log hooks\nReal DataStore and native encrypted VSS\nDisposable local fork; test funds only\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/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,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()Deciding log excerpts (elapsed milliseconds):
Test 6
247502 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1000)
250646 PUT_ERROR USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 InvalidData
254298 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1793238192650)
258494 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1000, required=1793238198795)
261151 TASK_ERROR cancelled JobCancellationException
264252 START_REQUEST
269650 PUT_OK USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 sha256=ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3
274429 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793238211711, required=1793238211711)
276366 ack=0 currency=USD statuses=USDT_RECOVERY=BackupItemStatus(running=false, synced=1793238211711, required=1793238211711)
279700 PUT_OK USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 sha256=ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3
279717 CALLBACK_ACK sha256=630d0f2da8ed9e42df4e1aecda66242e6eb7cedb91c8fd72b99424e75371b5a2
Test 8
304906 DEBOUNCE USDT_RECOVERY
306884 CALLBACK_ENTER sha256=630d0f2da8ed9e42df4e1aecda66242e6eb7cedb91c8fd72b99424e75371b5a2
306915 PUT_ENTER USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 sha256=ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3
307352 PUT_OK USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 sha256=ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3
307367 CALLBACK_ACK sha256=630d0f2da8ed9e42df4e1aecda66242e6eb7cedb91c8fd72b99424e75371b5a2
Test 9
808397 CALLBACK_ENTER sha256=5a6b450baf0c02ecdccdde5df15b1602c1de749ad4cb0531534fb385432863c9
819359 PUT_ERROR USDT_RECOVERY_V1_208ffafca501fbe01dc781b5be33a837647d9fd812899e6b7331b33a9b93ab8d UnknownException
819371 RETRY USDT_RECOVERY delay=10s revision=1793238750763
821605 PLAN_DIGEST 56922bfc32d71981a56b85496540feacd56d229dcbf3b2852e36a9b0edbadb3a
822587 HISTORY d0c84018-1564-41e0-bd94-8898e86d92cf:PENDING
838336 PLAN_DIGEST 56922bfc32d71981a56b85496540feacd56d229dcbf3b2852e36a9b0edbadb3a
841071 CALLBACK_ENTER sha256=5a6b450baf0c02ecdccdde5df15b1602c1de749ad4cb0531534fb385432863c9
841385 CALLBACK_ACK sha256=5a6b450baf0c02ecdccdde5df15b1602c1de749ad4cb0531534fb385432863c9
842415 NATIVE_REFRESH_DONE
845157 PLAN_DIGEST 56922bfc32d71981a56b85496540feacd56d229dcbf3b2852e36a9b0edbadb3a
846193 HISTORY d0c84018-1564-41e0-bd94-8898e86d92cf:PENDING
Test 10
879243 DEBOUNCE USDT_RECOVERY
879649 CALLBACK_ENTER sha256=d4dd41611f14b6a00271541807ff6adf046ef418fd2d2268a19c93f554841dd4
879674 PUT_ENTER USDT_RECOVERY_V1_d4a2ded5265a9f3aa6469c341e0645cfefc08b40872ae813d1187d266e67cb8b sha256=d4a2ded5265a9f3aa6469c341e0645cfefc08b40872ae813d1187d266e67cb8b
880118 PUT_OK USDT_RECOVERY_V1_d4a2ded5265a9f3aa6469c341e0645cfefc08b40872ae813d1187d266e67cb8b sha256=d4a2ded5265a9f3aa6469c341e0645cfefc08b40872ae813d1187d266e67cb8b
880133 CALLBACK_ACK sha256=d4dd41611f14b6a00271541807ff6adf046ef418fd2d2268a19c93f554841dd4
880221 SEND_RESULT 3f25cd35-339a-45a8-b041-5f15ad27c2b3 PENDING
Test 11
769953 HELD put:USDT_RECOVERY
771893 CALLBACK_ENTER sha256=630d0f2da8ed9e42df4e1aecda66242e6eb7cedb91c8fd72b99424e75371b5a2
778061 RELEASE put:USDT_RECOVERY
778931 PUT_OK USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 sha256=ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3
779145 PUT_OK USDT_RECOVERY_V1_ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3 sha256=ae5f313b703523b56c1c3ebeca7145e2bd073879702818d1ab46ad8ce2bd7aa3
779155 CALLBACK_ACK sha256=630d0f2da8ed9e42df4e1aecda66242e6eb7cedb91c8fd72b99424e75371b5a2
784352 RELEASE put:USDT_RECOVERY
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 pinned PR at 9a837a6e against merge-base 9ce43027. No prior coverage was inherited.
Code review completed with unverified earlier concerns.
Observation restart records new changes during the drain, then waits for the previous observation's cleanup before client setup and ordinary uploads. Reset waits for retained backup work before replacing either VSS client. An explicit USDT recovery callback reserves its revision and takes the category lock without sitting through the 5s coalescing debounce; it still waits for an upload already holding that lock. A permanent failure suppresses only the revision that failed, and a cancelled recovery callback does not increment Core's acknowledgement. Same-category manual work and a non-explicit retry wait for prior cleanup. Explicit USDT persistence does not require Lightning observation.
Shared backup contracts were compared with bitkit-ios#1123 at 566b2ff only. iOS likewise keeps explicit USDT persistence independent of node observation and treats only the anchored putObjects request-send error as a temporary unknown failure. Android drops observation immediately and waits for prior cleanup on restart; iOS waits on its shutdown boundary, as already described in journeys/backup-restore/wallet-snapshot-consistency.md. bitkit-android#1686 was the parent tree at 9ce43027, not a separate review.
Validation: BackupRepo.kt, BackupRepoTest.kt, and the observation-shutdown journey were inspected at the pinned revision. This review did not run just test, just compile, or just lint.
Device testing: not performed. Manual cases 1–5 need the fault-injection fixture named in that journey; those gates are not journey capabilities, so this review does not establish a device pass.
Earlier findings reconciled at this revision: 5 verified fixed, 1 could not be verified.
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 — Terminal identity failure blocks newer ordinary WALLET backups: 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.
Reviewer model: grok-4.7
Reasoning effort: high
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Full reassessment of 1d3ba149 against 217b42ef, also the merge base, because the comparison base changed. No coverage was inherited. The iOS twin and Android dependency received targeted backup-contract checks only, not full reviews.
Code review completed with unverified earlier concerns.
Validation: inspected the matching hosted build/test log, including all 113 BackupRepoTest passes. Its merge commit has the exact pinned parents and the same tree as the reviewed head. The local focused unit attempt stopped before compilation because this workspace lacks Android SDK platform 36.
Device testing: not performed in this review. Authored manual cases 1–5 remain unexecuted here; they require the dedicated lifecycle, transport and cache-write fault controls described in the pinned test specification. Mocked component coverage does not establish an authenticated app outage/recovery pass.
Earlier findings reconciled at this revision: 1 still present, 5 verified fixed, 2 could not be verified.
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 — Native LDK shutdown abort during VSS outage: The reviewer reports SIGABRT at older head 49e9a99 and an older #1686 base after a failed node_metrics write during shutdown. Retain this as an attributed unresolved observation. Android source and the dependency declaration cannot establish current-head recurrence or its native Tokio root cause. No matching current-head native execution or causal native-source proof was obtained; no fix, recurrence or introduction is claimed.
Could not verify earlier concern — Cold-start explicit backup Retry waits for node-dependent setup: Source confirms putObject awaits isSetup and normal observation starts with a Running node. The older reviewer reports an outage wait ending after node startup, also on master. This does not settle a current-head recovery defect or introduction: no current cold-start outage/recovery execution or complete native startup causal proof was obtained. Retain the limitation without asserting a fix or a new PR defect.
Existing concerns
- [HIGH] Terminal identity failure blocks newer ordinary WALLET backups — still unresolved; already tracked.
Reviewer model: gpt-6.1-sol
Reasoning effort: high
|
@ben-kaufman Moving this PR to 3.2.0 per the 3.1.0 cut line (https://synonymworkspace.slack.com/archives/C07BJ7DNPCG/p1793307568629859): the restore and profile-deletion stacks harden behaviour that already shipped in 3.0, so they keep landing on master and ship in 3.2. |
There was a problem hiding this comment.
Verdict: ⛔️ Request changes — the existing terminal-identity WALLET backup failure still reproduces at 1d3ba14.
Findings: No new findings in the contiguous delta. The existing HIGH remains: app/src/main/java/to/bitkit/repositories/BackupRepo.kt:958 cannot capture newer ordinary wallet state after terminal identity restoration failure. The native SDK returns identity_error before the completeness guard. This is unchanged from the previous head and current parent; the fresh result below corroborates the parent report.
The cancelled-reservation fix and debounce fix remain intact.
QA for 1d3ba14: Android 15 / API 35, arm64, disposable unfunded regtest wallet with staging Pubky and LNURL-authenticated encrypted VSS. I built the unmodified head, then used Debug controls to stage and inspect identity state. The outage build additionally routed only VSS through a forwarding relay that could close connections; Electrum remained reachable. Installed APK bytes matched the tested builds, and native libraries matched the unmodified APK. Videos are 2×; backup contents, status and timing assertions were checked in app/transport logs and DataStore.
Ordinary wallet backup after terminal identity failure ❌ Log and remote-backup comparisons confirmed a new ordinary allocation remained pending.
Invalidated the retained session and removed the disposable recovery secret, then reserved watch-only index 1. The real resolver reached terminal failure; WALLET capture returned SDK identity_error. The previous remote bytes/version remained unchanged and the new allocation stayed pending. Ordinary address allocation still worked. Restoring the original credentials enabled a complete WALLET upload and synchronized status, checked in logs and control output.
terminal-backup2x.2x.mp4
Data Backups during the controlled failure/recovery; nonvisual assertions checked separately.
Node shutdown during a VSS outage ✅ Logs confirmed shutdown completed and the same process survived the observed crash window.
With VSS connections failing and the emulator's cached-app freezer disabled, backgrounded the app. Native event handling logged a 30-second shutdown timeout, then shutdown completed. The same app process remained alive for more than 75 seconds after Node stopped; returning to the app resumed startup after transport recovery.
This one trial did not reproduce the earlier native abort. Its exact failed node_metrics precursor was not logged, so that report remains unresolved; this is not a fix claim. The timeout remains observed.
shutdown-outage2x.2x.mp4
Background, wait and return; native shutdown and process survival checked in logs.
Pending backups recover after a cold-start VSS outage ✅ Logs and DataStore confirmed pending metadata survived force-stop and completed without another request or edit.
Failed an explicit METADATA upload, force-stopped with its required revision retained, and relaunched while VSS remained unavailable. Another explicit request began at 06:14:34 CET and waited during node startup. Restored transport; the node started at 06:14:55 CET and METADATA succeeded 493 ms later. DataStore subsequently had running=false and synced=required.
The Debug command then also backed up SETTINGS and attempted WALLET. SETTINGS succeeded; WALLET temporarily returned shared_state_busy, then automatic retry and identity recovery synchronized it. The command's aggregate error is retained; the METADATA pass is established by its own upload/status evidence. This confirms recovery once the node starts, and preserves the earlier limitation that cold-start explicit backup waits for node-dependent VSS setup.
cold-start-retry2x.2x.mp4
Cold restart and recovery; upload ordering and revisions checked in logs and DataStore.
The following device passes are retained at 9a837a6 from the previous report, not new executions. Backup scheduling, DataStore, VSS transport, USDT persistence/callbacks, wipe implementation and dependency pins are byte-identical. The parent's SDK tracking extraction was inspected and its affected regressions passed.
Check: pending changes and due retry across restart ✅ Retained device pass at `9a837a6`.
Original case 1 and recording. Logs confirmed that replacement setup, settings and a due retry waited for cleanup, then uploaded the edit without another mutation.
Check: reset drains manual and recovery persistence ✅ Retained device pass at `9a837a6`.
Original case 2 and recording. Logs confirmed that cancelled reset callers still drained both held writes, and cancelled recovery received no acknowledgement.
Check: wipe closes explicit admission ✅ Retained device pass at `9a837a6`.
Original case 3 and recording. Logs confirmed no new upload or status change during wipe, followed by successful explicit recovery with observation stopped.
Check: full-backup follow-up and held status writes ✅ Retained device pass at `9a837a6`.
Original case 4 and recording. Logs confirmed successor uploads, a retained required-status write and a full request behind cleanup; decrypted readback contained CHF.
Check: explicit retry with observation stopped ✅ Retained device pass at `9a837a6`.
Original case 5a and recording. Logs confirmed native connection-failure retries with observation stopped; automatic success did not acknowledge the failed callback.
Check: cancelled queued recovery revision ✅ Retained device pass at `9a837a6`.
Original case 5b and recording. Log comparisons confirmed that older completion retained the newer cancelled reservation and could not acknowledge its callback.
Check: cancelled reservation after a permanent failure ✅ Retained device pass at `9a837a6`.
Original case 6 and recording. Logs confirmed that observation now uploads a newer cancelled reservation after permanent failure, without acknowledging the cancelled callback.
Check: explicit recovery callback during scheduled debounce ✅ Retained device pass at `9a837a6`.
Original case 8 and recording. App timestamps measured a 483 ms callback acknowledgement during idle debounce, with one recovery upload and no delayed duplicate.
Check: native signed operation after rejected backup ✅ Retained device pass at `9a837a6`.
Original case 9 and recording. App and fixture logs confirmed identical signed recovery bytes across rejection and native retry, with zero submissions before acknowledgement and one afterward.
Check: native signed send behind scheduled recovery ✅ Retained device pass at `9a837a6`.
Original case 10 and recording. App timestamps measured a 484 ms signed-send callback acknowledgement during idle debounce; fixture logs confirmed one new submission.
Check: explicit callback waits for active upload ✅ Retained device pass at `9a837a6`.
Original case 11 and recording. Logs confirmed that an explicit callback waits for an active older upload, then performs and acknowledges its own write.
Check: pinned build and affected regressions ✅ The unmodified head assembled and all selected suites passed.
Ran assembleDevDebug and testDevDebugUnitTest selecting BackupRepoTest, WalletConnectionRecoveryTest, AppViewModelSendFlowTest, PaykitPaymentRequestRepoTest, PaykitPaymentRequestRepoSubscriptionTest, PaykitSdkServiceTest and AddContactViewModelTest. Complete output and fresh JUnit reports were inspected. Build output retained the startup-provider, native-symbol stripping, JVM sharing and Gradle deprecation notices. This was focused verification; full-project tests and lint were not rerun.
Check: imported parent behavior ✅ Source comparison retains prior device evidence for the imported changes.
- Contact-preview report at d19deb5: Save during held endpoint discovery, Back cancellation and failed-save retry. Contact preview code is identical.
- Inbox report at bad5ae8: automatic presentation, review/PIN deferral, one authorized payment and delayed saved-contact refresh. The combined SDK receive/read implementation is identical; intervening repository changes already existed at the previously reviewed head.
- Passive-proof report: Background admission, warm-state retention and foreground recovery. The delta imports the tested refresh-priority selection.
- Send-admission report at fba00b0: held recovery reads, deferred ownership, hardware fee refusal/retry and stale navigation. Those functions are unchanged; the remaining AppViewModel differences select Background refresh for the separately reviewed contact/proof paths.
- Current-parent restore report: required WALLET/USDT recovery blocks payment links and survives restart. Those shared admission paths match this composition.
No selected case remains unrun. Fresh device work did not repeat complete WALLET restore, funded process-death payment recovery, contact-request negotiation or the imported hardware/payment journeys; retained evidence above remains limited to its recorded cases and revisions. The earlier SETTINGS-based full-backup case proves scheduler follow-up, not complete WALLET capture.
Twin: bitkit-ios#1123 fix: serialize backup observation lifecycle, inspected at 1dd6d2f, matches cleanup draining, bounded transport retry and explicit USDT persistence while observation is stopped. Android invalidates observation immediately and drains at restart/reset; iOS awaits its shutdown boundary. The separate terminal complete-WALLET limitation remains unresolved on both platforms.
Coverage: contiguous 9a837a6..1d3ba14, all 19 changed files and reached callers, with an independent source reviewer. The delta incorporates current parent 217b42e; backup production itself is unchanged. Live head remained 1d3ba14 at the final discussion recheck.
Debug controls and deciding output
Not pushed. Shell-only controls stage disposable credentials, invoke real repositories and compare real SDK/VSS/DataStore state. The outage-only override selects a VSS forwarding relay; it does not change retry, admission, capture or acknowledgement decisions. The relay forwarded encrypted requests to staging and closed connections during the outage.
terminal=true; ordinary watch-only index=1 reserved; failed=true; remote unchanged=true; pending=true; error=AppError
Backup failed for: 'WALLET' [AppError='code=identity_error, context=restore Pubky grant session from platform provider']
new address success=true; error=null
recovered original identity and complete WALLET upload
backup synchronized; sdk=true
Shutdown: event handling timed out after 30 seconds; Shutdown complete; Node stopped
Same app process remained alive more than 75 seconds after Node stopped
Cold start: METADATA required revision retained across force-stop
Backup starting for METADATA preceded node startup and VSS client setup
Node started -> METADATA succeeded 493 ms later
METADATA running=false, synced=required
WALLET shared_state_busy -> automatic temporary retries -> WALLET succeeded
diff --git a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
index 00c57a69a..c57ec93a7 100644
--- a/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
+++ b/app/src/debug/java/to/bitkit/dev/DevToolsProvider.kt
@@ -42,6 +42,11 @@ class DevToolsProvider : ContentProvider() {
override fun call(method: String, arg: String?, extras: Bundle?): Bundle {
check(Binder.getCallingUid() == SHELL_UID) { "Only ADB shell callers are allowed" }
+ if (method.startsWith("qa1686")) {
+ return kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) {
+ Qa1686.run(requireNotNull(context), method)
+ }
+ }
return runCatching {
val command = requireNotNull(DevCommand.parse(method, arg)) { "Unknown command: '$method'" }
ServiceQueue.LDK.blocking { command.execute(deps) }
diff --git a/app/src/debug/java/to/bitkit/dev/Qa1686.kt b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
new file mode 100644
--- /dev/null
+++ b/app/src/debug/java/to/bitkit/dev/Qa1686.kt
@@ -0,0 +1,133 @@
+package to.bitkit.dev // test hook
+
+import android.content.Context // test hook
+import android.content.ClipData // test hook
+import android.content.ClipboardManager // test hook
+import android.os.Bundle // test hook
+import dagger.hilt.EntryPoint // test hook
+import dagger.hilt.InstallIn // test hook
+import dagger.hilt.android.EntryPointAccessors // test hook
+import dagger.hilt.components.SingletonComponent // test hook
+import kotlinx.coroutines.Dispatchers // test hook
+import kotlinx.coroutines.flow.first // test hook
+import kotlinx.coroutines.withContext // test hook
+import to.bitkit.data.CacheStore // test hook
+import to.bitkit.data.backup.VssBackupClient // test hook
+import to.bitkit.data.keychain.Keychain // test hook
+import to.bitkit.models.BackupCategory // test hook
+import to.bitkit.repositories.BackupRepo // test hook
+import to.bitkit.repositories.PubkyRepo // test hook
+import to.bitkit.repositories.WalletRepo // test hook
+import to.bitkit.repositories.LightningRepo // test hook
+import to.bitkit.services.PubkyService // test hook
+import to.bitkit.di.json // test hook
+import to.bitkit.models.WalletBackupV1 // test hook
+import to.bitkit.utils.Logger // test hook
+import java.io.File // test hook
+
+object Qa1686 { // test hook
+ @EntryPoint @InstallIn(SingletonComponent::class) // test hook
+ interface Dependencies { // test hook
+ fun keychain(): Keychain // test hook
+ fun backupRepo(): BackupRepo // test hook
+ fun pubkyRepo(): PubkyRepo // test hook
+ fun pubkyService(): PubkyService // test hook
+ fun vss(): VssBackupClient // test hook
+ fun cache(): CacheStore // test hook
+ fun wallet(): WalletRepo // test hook
+ fun accounts(): to.bitkit.data.WatchOnlyAccountStore
+ fun settings(): to.bitkit.data.SettingsStore
+ fun lightning(): LightningRepo // test hook
+ } // test hook
+ suspend fun run(context: Context, method: String): Bundle { // test hook
+ val d = EntryPointAccessors.fromApplication(context, Dependencies::class.java) // test hook
+ val result = kotlin.runCatching { // test hook
+ when (method) { // test hook
+ "qa1686InitCore" -> { // test hook
+ to.bitkit.async.ServiceQueue.CORE.background { // test hook
+ com.synonym.bitkitcore.initDb(to.bitkit.env.Env.bitkitCoreStoragePath(0)) // test hook
+ com.synonym.bitkitcore.updateBlocktankUrl(to.bitkit.env.Env.blocktankApiUrl) // test hook
+ } // test hook
+ "initialized real native database after app storage path became available" // test hook
+ } // test hook
+ "qa1686QuickState" -> {
+ "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, identity=${d.pubkyRepo().publicKey.value != null}, authenticated=${d.pubkyRepo().isAuthenticated.value}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}"
+ }
+ "qa1686CreateProfile" -> {
+ d.pubkyRepo().createIdentity("QA restore", "", emptyList(), emptyList(), null).getOrThrow()
+ "created disposable profile"
+ }
+ "qa1686Backup" -> {
+ for (c in listOf(BackupCategory.METADATA, BackupCategory.SETTINGS, BackupCategory.WALLET)) d.backupRepo().triggerBackup(c).getOrThrow()
+ val wallet = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val metadata = requireNotNull(d.vss().getObject("METADATA").getOrThrow())
+ val pending = d.cache().backupStatuses.first().filterKeys { it in listOf(BackupCategory.WALLET, BackupCategory.METADATA) }.any { it.value.isRequired }
+ check(!pending)
+ "backup synchronized; sdk=${json.decodeFromString<WalletBackupV1>(wallet.value.decodeToString()).paykitSdkBackupState != null}; metadataBytes=${metadata.value.size}"
+ }
+ "qa1686RequiredFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained WALLET recovery payload"
+ }
+ "qa1686UsdtFault" -> {
+ d.backupRepo().stopObservingBackups()
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ File(context.filesDir, "qa1686-valid-wallet").writeBytes(before.value)
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, "invalid-json")
+ "staged malformed retained USDT recovery payload"
+ }
+ "qa1686Terminal" -> {
+ d.backupRepo().stopObservingBackups()
+ val secret = requireNotNull(d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name))
+ File(context.filesDir, "qa1686-original-secret").writeText(secret)
+ val session = requireNotNull(d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name))
+ File(context.filesDir, "qa1686-original-session").writeText(session)
+ d.pubkyService().forgetSessionAccess()
+ d.keychain().delete(Keychain.Key.PUBKY_SECRET_KEY.name)
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, "qa1686-invalid-session")
+ d.pubkyRepo().initialize().getOrThrow()
+ check(d.pubkyRepo().publicKey.value == null)
+ val before = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val reserved = d.accounts().reserveAccountIndex(0, "qa1686-terminal-ordinary")
+ check(d.accounts().backupSnapshot().allocationState.highestAccountIndexByWallet["0"] == reserved)
+ val failed = d.backupRepo().triggerBackup(BackupCategory.WALLET)
+ val after = requireNotNull(d.vss().getObject("WALLET").getOrThrow())
+ val same = before.value.contentEquals(after.value) && before.version == after.version
+ val pending = d.cache().backupStatuses.first()[BackupCategory.WALLET]?.isRequired == true
+ check(failed.isFailure && same && pending)
+ "terminal=true; ordinary watch-only index=$reserved reserved; failed=true; remote unchanged=true; pending=true; error=${failed.exceptionOrNull()?.javaClass?.simpleName}"
+ }
+ "qa1686RepairIdentity" -> {
+ d.keychain().upsertString(Keychain.Key.PUBKY_SECRET_KEY.name, File(context.filesDir, "qa1686-original-secret").readText())
+ d.keychain().upsertString(Keychain.Key.PAYKIT_SESSION.name, File(context.filesDir, "qa1686-original-session").readText())
+ d.pubkyRepo().initialize().getOrThrow()
+ d.backupRepo().triggerBackup(BackupCategory.WALLET).getOrThrow()
+ "recovered original identity and complete WALLET upload"
+ }
+ "qa1686RepairRequired" -> { // test hook
+ d.keychain().upsertString(Keychain.Key.PAYKIT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ if (d.keychain().exists(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name)) { // test hook
+ d.keychain().upsertString(Keychain.Key.USDT_PENDING_BACKUP_RESTORE.name, File(context.filesDir, "qa1686-valid-wallet").readText()) // test hook
+ } // test hook
+ "repaired original required backup" // test hook
+ } // test hook
+ "qa1686SpendingGate" -> { // test hook
+ check(d.backupRepo().hasPendingWalletRestore()) // test hook
+ val result = d.lightning().payInvoice("qa1686-invalid-invoice") // test hook
+ check(result.exceptionOrNull()?.javaClass?.simpleName == "WalletBackupRestorePendingError") // test hook
+ "payment admission blocked by required restore before invoice parsing" // test hook
+ } // test hook
+ "qa1686AddressGate" -> { // test hook
+ val result = d.wallet().newAddress() // test hook
+ "new address success=${result.isSuccess}; error=${result.exceptionOrNull()?.javaClass?.simpleName}" // test hook
+ } // test hook
+ else -> "source=${d.keychain().exists(Keychain.Key.SHARED_PUBKY_SOURCE.name)}, savedSecret=${!d.keychain().loadString(Keychain.Key.PUBKY_SECRET_KEY.name).isNullOrBlank()}, savedSession=${!d.keychain().loadString(Keychain.Key.PAYKIT_SESSION.name).isNullOrBlank()}, sdk=${d.pubkyService().currentPublicKey() != null}, identity=${d.pubkyRepo().publicKey.value != null}, profile=${d.pubkyRepo().profile.value?.name}, contacts=${d.pubkyRepo().contacts.value.size}, pendingRestore=${d.backupRepo().hasPendingWalletRestore()}, node=${d.lightning().lightningState.value.nodeLifecycleState}" // test hook
+ } // test hook
+ }.fold({ it }, { "ERROR: ${it.javaClass.simpleName}: ${it.message}" }) // test hook
+ Logger.info("QA1686 $method: $result", context="TestHook") // test hook
+ return Bundle().apply { putString("result", result) } // test hook
+ } // test hook
+} // test hook
diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt
--- a/app/src/main/java/to/bitkit/env/Env.kt
+++ b/app/src/main/java/to/bitkit/env/Env.kt
@@ -102,6 +102,6 @@
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"
}Reviewed by gpt-6.1-sol-high
Current 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.Current 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.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 — lifecycle fault gates are not in Capabilities.
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 — reset/cache/VSS gates are not in Capabilities.
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 — controlled wipe admission is not in Capabilities.
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 — capture/upload/cache-write gates are not in Capabilities.
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 — controlled transport/upload/cache boundaries are not in Capabilities.
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)