From 561971741c7758129d3f9bb85d5ffe2adbb8fad8 Mon Sep 17 00:00:00 2001 From: bfoss765 <38437574+bfoss765@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:21:14 -0400 Subject: [PATCH 1/2] fix: MO-995 | retry the SDK wallet bind and hold the cutover until it succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field outage (both of Andrei's devices): during fresh-wallet setup the Kotlin SDK's createWallet failed with UserNotAuthenticatedException from the Android keystore (a setUnlockedDeviceRequired key denied because Keystore2 believed the device was locked — sometimes falsely). The SDK rolled its wallet back cleanly, but the app then stranded the user with NO sync engine forever: - CutoverCoordinator commits DUAL_RUNNING -> CUT_OVER at fresh-wallet setup BEFORE the bind ever runs, so dashj is held; - SdkWalletBinder is single-shot per trigger — one failed pass never reruns, while CutoverUiDataService's 5 s wait loop only OBSERVES the bound state without ever re-invoking the bind; - nothing is surfaced: the Network monitor shows a dead "Not started" / "Network engine not started" (L1SyncStage.IDLE). Three changes: 1. Hold-then-rollback on the cutover. The fresh commit cannot be deferred to bind success (it is what routes the fresh-wallet launch; deferring would leave both SPV engines live mid-launch), so the escape hatch is CutoverCoordinator.rollbackForFailedBind: after 5 consecutive failed bind passes (skipped while the device is provably locked) the gate rolls CUT_OVER back to DUAL_RUNNING and BlockchainServiceImpl — which now observes CUTOVER_STATE live — un-holds and starts the dashj fallback engine mid-launch. Invariant: the gate always ends with dashj allowed OR the SDK wallet bound, never both held. 2. Re-armable bind. SdkWalletBinder tracks the failed-pass state (bindRetryPending + consecutiveBindFailures); the new SdkBindRetryService re-invokes the bind on a capped 5s/15s/30s/60s- then-hourly ladder (reset on app foreground), driven by the existing CutoverUiDataService wait loop, and registers a runtime ACTION_USER_PRESENT receiver (RECEIVER_NOT_EXPORTED) that fires an immediate retry on device unlock — the exact heal condition for the keystore false-locked class. 3. Surface the failure. A pending bind retry renders as the new L1SyncStage.SETUP_RETRYING in the Network monitor ("Wallet setup incomplete — retrying" / "Unlock your device to finish wallet setup") instead of the dead IDLE readout. The SDK-side hardening (typed keystore error + internal createWallet retry) is a deliberately separate follow-up. Co-Authored-By: Claude Fable 5 --- wallet/res/values/strings.xml | 2 + .../wallet/service/BlockchainServiceImpl.kt | 60 +++ .../wallet/service/L1SyncStatusService.kt | 39 +- .../platform/sdk/CutoverCoordinator.kt | 50 ++ .../platform/sdk/CutoverUiDataService.kt | 32 +- .../platform/sdk/SdkBindRetryService.kt | 323 ++++++++++++ .../service/platform/sdk/SdkWalletBinder.kt | 77 +++ .../wallet/ui/NetworkMonitorViewModel.kt | 5 + .../wallet/service/L1SyncStatusServiceTest.kt | 46 ++ .../platform/sdk/CutoverCoordinatorTest.kt | 35 ++ .../platform/sdk/CutoverUiDataServiceTest.kt | 37 +- .../platform/sdk/SdkBindRetryServiceTest.kt | 489 ++++++++++++++++++ .../wallet/ui/NetworkMonitorViewModelTest.kt | 6 + 13 files changed, 1192 insertions(+), 9 deletions(-) create mode 100644 wallet/src/de/schildbach/wallet/service/platform/sdk/SdkBindRetryService.kt create mode 100644 wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt diff --git a/wallet/res/values/strings.xml b/wallet/res/values/strings.xml index 872a6773f4..a33b12e326 100644 --- a/wallet/res/values/strings.xml +++ b/wallet/res/values/strings.xml @@ -199,6 +199,7 @@ Scanning block filters Synced Sync error + Wallet setup incomplete — retrying %1$d%% Block headers Block filters @@ -210,6 +211,7 @@ Searching for peers… Network engine not started Connection error + Unlock your device to finish wallet setup The wallet engine does not report individual peer connections. Peer and block lists come from the dashj diagnostic engine. Turn on dashj sync in Tools to view them. Mining difficulty adjustment diff --git a/wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt b/wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt index de4f6e750e..cc80a185bc 100644 --- a/wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt +++ b/wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt @@ -207,6 +207,11 @@ class BlockchainServiceImpl : LifecycleService(), BlockchainService { override fun onStart(owner: LifecycleOwner) { log.info("App moved to foreground") isAppInBackground = false + // MO-995: a pending SDK bind retry escapes the hourly backoff + // tail the moment the user is actually looking at the app. + if (::sdkBindRetryService.isInitialized) { + sdkBindRetryService.noteAppForeground() + } } override fun onStop(owner: LifecycleOwner) { @@ -285,6 +290,7 @@ class BlockchainServiceImpl : LifecycleService(), BlockchainService { @Inject lateinit var dashPayConfig: de.schildbach.wallet.ui.dashpay.utils.DashPayConfig @Inject lateinit var l1ShadowSyncService: de.schildbach.wallet.service.platform.sdk.L1ShadowSyncService @Inject lateinit var sdkWalletBinder: de.schildbach.wallet.service.platform.sdk.SdkWalletBinder + @Inject lateinit var sdkBindRetryService: de.schildbach.wallet.service.platform.sdk.SdkBindRetryService @Inject lateinit var dashjDiagnosticSyncState: DashjDiagnosticSyncState /** @@ -1531,6 +1537,49 @@ class BlockchainServiceImpl : LifecycleService(), BlockchainService { } } } + + /** + * MO-995: a cutover ROLLBACK must un-hold the dashj engine on the + * LIVE service. The engine gate is resolved once per launch + * (onCreate), so when [CutoverCoordinator.rollbackForFailedBind] + * rolls a bind-stranded fresh wallet back to DUAL_RUNNING mid-launch + * (or the debug ROLLBACK_CUTOVER broadcast fires), nothing used to + * start the fallback engine until the next app launch — exactly the + * "no sync engine at all" outage this exists to end. Mirrors + * [onDashjDiagnosticChanged]'s live re-resolution under [checkMutex]. + * + * Deliberately UN-HOLD only: the commit direction (a mid-launch + * auto-commit flipping `coordinatorAllowsDashj` false) keeps today's + * behavior — the running dashj engine finishes the launch and the + * hold takes effect on the next one. Stopping a live primary engine + * on commit is a separate decision this fix does not make. + */ + fun onCutoverStateChanged() { + serviceScope.launch { + onCreateCompleted.await() + checkMutex.withLock { + try { + val coordinatorAllowsDashj = runCatching { cutoverCoordinator.dashjEngineMayStart() } + .getOrDefault(true) + val newEngineMayStart = coordinatorAllowsDashj || dashjSyncDiagnostic + if (!coordinatorAllowsDashj || newEngineMayStart == dashjEngineMayStart) { + return@withLock + } + dashjHeldByCutover = false + dashjEngineMayStart = true + log.info( + "cutover state rolled back mid-launch — un-holding the dashj L1 " + + "engine (dashjEngineMayStart=true); starting the fallback sync" + ) + if (peerGroup == null) { + checkService() + } + } catch (e: Exception) { + log.error("onCutoverStateChanged failed", e) + } + } + } + } } /** @@ -2112,6 +2161,17 @@ class BlockchainServiceImpl : LifecycleService(), BlockchainService { .onEach { enabled -> (networkCallback as? NetworkCallbackImpl)?.onDashjDiagnosticChanged(enabled) } .launchIn(serviceScope) + // MO-995: keep the cutover ROLLBACK effective on a LIVE service — + // the bind-failure fallback (CutoverCoordinator.rollbackForFailedBind) + // rolls CUT_OVER back to DUAL_RUNNING mid-launch, and the one-shot + // gate above would otherwise leave the wallet engine-less until the + // next app launch. Un-hold direction only; the first (current-value) + // emission is a no-op (the gate was just resolved from it). + dashPayConfig.observe(de.schildbach.wallet.ui.dashpay.utils.DashPayConfig.CUTOVER_STATE) + .distinctUntilChanged() + .onEach { (networkCallback as? NetworkCallbackImpl)?.onCutoverStateChanged() } + .launchIn(serviceScope) + onCreateCompleted.complete(Unit) log.info(".onCreate() finished") } catch (t: Throwable) { diff --git a/wallet/src/de/schildbach/wallet/service/L1SyncStatusService.kt b/wallet/src/de/schildbach/wallet/service/L1SyncStatusService.kt index e9d9535d64..eca24b5866 100644 --- a/wallet/src/de/schildbach/wallet/service/L1SyncStatusService.kt +++ b/wallet/src/de/schildbach/wallet/service/L1SyncStatusService.kt @@ -19,6 +19,7 @@ package de.schildbach.wallet.service import de.schildbach.wallet.service.platform.sdk.CutoverCoordinator import de.schildbach.wallet.service.platform.sdk.L1ShadowSyncService +import de.schildbach.wallet.service.platform.sdk.SdkWalletBinder import de.schildbach.wallet.service.platform.sdk.ShadowSyncPhase import de.schildbach.wallet.service.platform.sdk.ShadowSyncProgress import de.schildbach.wallet.service.platform.sdk.shadowSyncPercent @@ -267,8 +268,15 @@ internal fun dashPaySyncSettledWithDeadline( * Monitor) — finer-grained than [org.dash.wallet.common.data.SyncStage] * (which collapses the filter pipeline for the home header), with NO * indication of which engine produced it. + * + * [SETUP_RETRYING] (MO-995): the engine cannot start because the wallet's + * one-time setup (the SDK wallet bind) failed and is being retried — the + * keystore-denial outage class. Rendered instead of the dead + * [IDLE]/"Not started" so the user learns that unlocking the device + * finishes setup, rather than staring at "Network engine not started" + * forever. */ -enum class L1SyncStage { IDLE, CONNECTING, HEADERS, FILTER_HEADERS, MASTERNODE_LIST, FILTERS, SYNCED, ERROR } +enum class L1SyncStage { IDLE, CONNECTING, HEADERS, FILTER_HEADERS, MASTERNODE_LIST, FILTERS, SYNCED, ERROR, SETUP_RETRYING } /** * The detailed engine-agnostic L1 sync readout for the Network Monitor: @@ -340,17 +348,31 @@ internal fun toL1SyncStage(progress: ShadowSyncProgress): L1SyncStage = when { * * dashj regime: the row is all dashj has (no filter pipeline, no header * target), so filter fields stay 0/unknown. + * + * [bindRetryPending] (MO-995): a failed SDK wallet bind is awaiting retry + * ([SdkWalletBinder.bindRetryPending]). In the SDK regime this replaces an + * [L1SyncStage.IDLE] stage with [L1SyncStage.SETUP_RETRYING] — IDLE only, + * because the pending flag can only coexist with a DEAD engine (an unbound + * wallet has no engine to run); any real progress means the wallet bound + * and the honest scan stage must win. The dashj regime ignores it: after + * the bind-failure rollback dashj is the engine and its real stages render. */ internal fun mergeL1SyncDetail( sdkOwnsL1: Boolean, progress: ShadowSyncProgress, sessionChainLockHeight: Int, - state: BlockchainState? + state: BlockchainState?, + bindRetryPending: Boolean = false ): L1SyncDetail = if (sdkOwnsL1) { val idleOrConnecting = progress.phase == ShadowSyncPhase.IDLE || progress.phase == ShadowSyncPhase.CONNECTING + val scanStage = toL1SyncStage(progress) L1SyncDetail( - stage = toL1SyncStage(progress), + stage = if (bindRetryPending && scanStage == L1SyncStage.IDLE) { + L1SyncStage.SETUP_RETRYING + } else { + scanStage + }, // Restart sawtooth guard: IDLE/CONNECTING carry no scan position, // so fall back to the row's (SDK-written, preserved) percent // rather than reporting 0 over a previously-synced chain. @@ -409,6 +431,7 @@ class L1SyncStatusService @Inject constructor( l1ShadowSyncService: L1ShadowSyncService, blockchainStateProvider: BlockchainStateProvider, dashPaySyncStatus: DashPaySyncStatus, + sdkWalletBinder: SdkWalletBinder, scope: CoroutineScope ) { /** @@ -486,14 +509,18 @@ class L1SyncStatusService @Inject constructor( * The engine-agnostic DETAIL readout for the Network Monitor * ([mergeL1SyncDetail]) — same seam discipline as [status]: the engine * choice is made here, reactively, and nothing above can observe it. + * The binder's [SdkWalletBinder.bindRetryPending] rides along so a + * failed wallet bind renders as [L1SyncStage.SETUP_RETRYING] instead of + * the dead "Not started" (MO-995). */ val details: Flow = combine( cutoverCoordinator.sdkOwnsL1Flow(), l1ShadowSyncService.progress, l1ShadowSyncService.chainLockHeight, - blockchainStateProvider.observeState() - ) { sdkOwnsL1, progress, chainLockHeight, state -> - mergeL1SyncDetail(sdkOwnsL1, progress, chainLockHeight, state) + blockchainStateProvider.observeState(), + sdkWalletBinder.bindRetryPending + ) { sdkOwnsL1, progress, chainLockHeight, state, bindRetryPending -> + mergeL1SyncDetail(sdkOwnsL1, progress, chainLockHeight, state, bindRetryPending) }.distinctUntilChanged() } diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverCoordinator.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverCoordinator.kt index af55d21e8c..5225ae4b82 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverCoordinator.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverCoordinator.kt @@ -176,6 +176,14 @@ class CutoverCoordinator @Inject constructor( * SDK path is inactive this is a deliberate no-op and the wallet stays * on dashj. Only advances a pre-commit state; never clobbers * CUT_OVER/SETTLED. Never throws. + * + * MO-995 escape hatch: this commit lands BEFORE the first SDK wallet + * bind runs (it has to — see [rollbackForFailedBind] for why deferring + * it is not possible), so a bind that then fails persistently + * (keystore denial) would hold dashj with nothing to replace it. The + * bind-failure rollback ([rollbackForFailedBind], driven by + * [SdkBindRetryService]) undoes this commit in that case, restoring + * the dashj fallback engine. */ /** * Fire-and-forget [commitForFreshWalletSetup] for the Java `setWallet` @@ -299,6 +307,48 @@ class CutoverCoordinator @Inject constructor( commitLocked("fresh-wallet setup (restore/new)").first } + /** + * MO-995 bind-failure fallback: roll a committed cutover back to + * DUAL_RUNNING because the SDK wallet bind keeps failing — after this, + * [dashjEngineMayStart] is true again and the user syncs on the dashj + * fallback engine instead of being stranded with NO engine at all. + * + * WHY a rollback and not a deferred commit: the fresh-wallet commit + * ([commitForFreshWalletSetupAsync]) cannot wait for the first + * successful bind, because the commit IS what routes the fresh-wallet + * launch — [de.schildbach.wallet.service.BlockchainServiceImpl] + * resolves the engine gate once at service onCreate (right after + * `setWallet`), while the first bind pass only runs when platform sync + * starts. A deferred commit would let the dashj peergroup start on + * EVERY fresh wallet and then land mid-launch, leaving both SPV + * engines live for the rest of the session (the "never two live SPV + * engines" invariant). So the commit stays immediate and THIS is the + * escape hatch: [SdkBindRetryService] calls it once + * [SdkWalletBinder.consecutiveBindFailures] passes its threshold + * (skipping it while the device is provably locked — a locked-device + * keystore denial heals on unlock and must not flip engines). + * + * Legal only from CUT_OVER (mirrors the state machine's ROLLBACK edge — + * SETTLED is past the migration horizon and never regresses); a no-op + * from any other state. The live engine un-hold is + * BlockchainServiceImpl's job: it observes CUTOVER_STATE and starts the + * dashj peergroup when a rollback lands mid-launch. Recovery is + * symmetric — once a later bind pass succeeds, the auto-commit observer + * re-earns CUT_OVER through the normal readiness policy. Never throws. + */ + suspend fun rollbackForFailedBind(consecutiveFailures: Int): CutoverStatus = mutex.withLock { + val current = currentState() + if (current != CutoverState.CUT_OVER) { + return@withLock CutoverStatus(current, READY_VERDICT) + } + writeState( + current, + CutoverState.DUAL_RUNNING, + "SDK wallet bind failed $consecutiveFailures consecutive passes — " + + "falling back to the dashj engine so the wallet is never left with no L1 engine" + ) + } + /** * The immediate (non-readiness) commit, plus whether THIS call is the * one that moved the state to CUT_OVER. Both halves are computed under diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt index 5e96ba94c6..76c6dd12dd 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/CutoverUiDataService.kt @@ -1870,6 +1870,16 @@ class CutoverUiDataService internal constructor( * snapshot-only tests. */ private val loadPersistedInstantLocks: suspend (Collection) -> Set = { emptySet() }, + /** + * MO-995: re-arm a FAILED [SdkWalletBinder] pass while [awaitBoundWallet] + * is stuck waiting — [SdkBindRetryService.maybeRetry], which self-gates + * on a pending bind failure and its own capped backoff, so calling it on + * every 5 s poll is cheap and correct. Without this the wait loop only + * ever OBSERVED the bound state and a single keystore-denied bind pass + * stranded the pipelines (and the user's sync engine) forever. Default + * no-op for the fake-fed tests. + */ + private val retryBind: suspend () -> Unit = {}, private val nowMs: () -> Long = System::currentTimeMillis, private val refreshIntervalMs: Long = REFRESH_INTERVAL_MS, private val walletBindRetryMs: Long = WALLET_BIND_RETRY_MS, @@ -1903,7 +1913,8 @@ class CutoverUiDataService internal constructor( sdkTxContactResolver: SdkTxContactResolver, instantSendLockDao: de.schildbach.wallet.database.dao.InstantSendLockDao, dashPayBackfillGate: DashPayBackfillGate, - dashPaySyncStatus: de.schildbach.wallet.service.DashPaySyncStatus + dashPaySyncStatus: de.schildbach.wallet.service.DashPaySyncStatus, + sdkBindRetryService: SdkBindRetryService ) : this( source = DashSdkCutoverUiSource(sdkService), dashPayConfig = dashPayConfig, @@ -1925,6 +1936,7 @@ class CutoverUiDataService internal constructor( // this was a second hand-copied `synced || scanCaughtUpToTip` // expression that had to be kept in lockstep by hand. l1Synced = l1SyncStatusService.sdkScanCaughtUp, + retryBind = { sdkBindRetryService.maybeRetry("cutover-ui bound-wallet wait") }, rescanRecentlyArmed = { sdkService.spvRescanArmedWithin(RESCAN_ARM_PERSIST_HOLD_MS) }, deferredContactBuildCount = { walletIdHex -> sdkService.dashPayPendingAccountBuilds(walletIdHex) }, dashPayBackfillStatus = { dashPayBackfillGate.readBackfillStatus() }, @@ -2425,6 +2437,13 @@ class CutoverUiDataService internal constructor( * post-reset/restore orphan window, until the binder's orphan prune runs) — * that state used to park the ENTIRE cutover UI pipeline with zero log * output; now it says so once a minute. + * + * MO-995: each poll also CONSULTS the bind retry machinery ([retryBind]) + * — this loop runs exactly while the cutover holds dashj but no SDK + * wallet is bound, which is the stranded no-engine state a single failed + * (keystore-denied) bind pass used to leave behind forever. The retry + * service applies its own capped backoff, so the 5 s poll cadence never + * hammers the keystore. */ private suspend fun awaitBoundWallet(): String { var attempts = 0 @@ -2437,10 +2456,19 @@ class CutoverUiDataService internal constructor( null } if (id != null) return id + try { + retryBind() + } catch (t: Throwable) { + if (t is CancellationException) throw t + // Belt over the retry service's own never-throws contract: + // this wait loop must survive anything the retry does. + log.warn("bind retry consultation failed; the wait loop continues", t) + } if (attempts++ % 12 == 0) { log.warn( "cutover UI pipelines waiting for a SINGLE bound SDK wallet (none, or more than " + - "one loaded — post-reset orphan not pruned yet?); retrying every {}ms", + "one loaded — post-reset orphan not pruned yet, or a failed bind pending " + + "retry?); retrying every {}ms", walletBindRetryMs ) } diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkBindRetryService.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkBindRetryService.kt new file mode 100644 index 0000000000..9e6979fdde --- /dev/null +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkBindRetryService.kt @@ -0,0 +1,323 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.service.platform.sdk + +import android.app.KeyguardManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import androidx.core.content.ContextCompat +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Delay BEFORE bind retry attempt number `retriesAttempted + 1` — the + * capped ladder 5s / 15s / 30s / 60s, then hourly. The first steps are + * tight because the field failure class (a keystore that FALSELY reports + * the device locked) often clears within seconds; the hourly tail keeps a + * genuinely broken keystore from burning battery forever while the + * device-unlock receiver still heals it instantly. Pure — host-testable. + */ +internal fun bindRetryDelayMs(retriesAttempted: Int): Long = when (retriesAttempted) { + 0 -> 5_000L + 1 -> 15_000L + 2 -> 30_000L + 3 -> 60_000L + else -> 60 * 60_000L +} + +/** + * MO-995: re-arms the single-shot [SdkWalletBinder] after a failed bind + * pass — the app-side fix for the fresh-wallet sync outage where the SDK's + * `createWallet` died in the Android keystore + * (`UserNotAuthenticatedException` from a `setUnlockedDeviceRequired` key: + * Keystore2 thought the device was locked, sometimes falsely), the + * fresh-wallet cutover commit held dashj, and NOTHING ever retried the + * bind — leaving the wallet with no sync engine at all and the Network + * Monitor showing a dead "Not started". + * + * Three cooperating mechanisms: + * + * 1. **Backoff-capped re-invocation** ([maybeRetry]) — driven by + * [CutoverUiDataService]'s existing 5 s bound-wallet wait loop, which + * runs exactly while the cutover is committed but no SDK wallet is + * bound (the stranded state). The loop calls this every poll; the + * ladder ([bindRetryDelayMs]) decides which polls actually re-run the + * bind pass. [noteAppForeground] resets the ladder so a user returning + * to the app is never stuck behind the hourly tail. + * 2. **Device-unlock heal** — a runtime-registered + * [Intent.ACTION_USER_PRESENT] receiver (RECEIVER_NOT_EXPORTED) fires + * an immediate retry on the next unlock: the exact heal condition for + * the keystore false-locked class. Armed once, on the first retry + * consultation after a failure; retries once the wallet is bound are + * cheap no-ops. + * 3. **Engine fallback** — after [rollbackAfterFailures] consecutive + * failed passes ([SdkWalletBinder.consecutiveBindFailures]) the + * committed cutover is rolled back + * ([CutoverCoordinator.rollbackForFailedBind]) so + * `dashjEngineMayStart` is true again and the user syncs on the dashj + * fallback engine. Skipped while the device is PROVABLY locked + * ([KeyguardManager.isDeviceLocked]) — a genuinely-locked keystore + * denial is expected, heals on unlock, and must not flip engines. + * + * The invariant all three protect: the gate always ends with dashj + * allowed OR the SDK wallet bound — never both held. + * + * Never throws into a caller; every entry point contains its own failures. + * The SDK-side hardening (a typed keystore error + internal retry in + * `createWallet`) is a deliberately separate follow-up. + */ +@Singleton +class SdkBindRetryService internal constructor( + private val scope: CoroutineScope, + /** [SdkWalletBinder.bindRetryPending]'s current value. */ + private val bindRetryPending: () -> Boolean, + /** [SdkWalletBinder.consecutiveBindFailures]. */ + private val consecutiveBindFailures: () -> Int, + /** One full bind pass — [SdkWalletBinder.bindIfEnabled], which never throws. */ + private val runBindPass: suspend () -> Unit, + /** [CutoverCoordinator.rollbackForFailedBind]. */ + private val rollbackCutover: suspend (Int) -> Unit, + /** + * Register the unlock receiver; the callback fires on every + * ACTION_USER_PRESENT. Returns whether registration succeeded (a + * failure re-arms on the next consultation). + */ + private val registerUnlockReceiver: (onUserPresent: () -> Unit) -> Boolean, + /** + * Whether the device is PROVABLY locked right now. True suppresses the + * engine rollback (see class KDoc); the false-locked keystore class + * reads false here, which is exactly when the rollback must fire. + */ + private val deviceProvablyLocked: () -> Boolean = { false }, + private val now: () -> Long = System::currentTimeMillis, + private val retryDelayMs: (Int) -> Long = ::bindRetryDelayMs, + private val rollbackAfterFailures: Int = ROLLBACK_AFTER_CONSECUTIVE_FAILURES +) { + @Inject + constructor( + @ApplicationContext context: Context, + binder: SdkWalletBinder, + nonInteractiveWalletUnlock: NonInteractiveWalletUnlock, + cutoverCoordinator: CutoverCoordinator, + scope: CoroutineScope + ) : this( + scope = scope, + bindRetryPending = { binder.bindRetryPending.value }, + consecutiveBindFailures = { binder.consecutiveBindFailures }, + // The same non-interactive unlock recipe every background binding + // trigger uses (PlatformSyncService.kickSdkEngines) — never a prompt. + runBindPass = { binder.bindIfEnabled(nonInteractiveWalletUnlock::unlockOrNull) }, + rollbackCutover = { failures -> cutoverCoordinator.rollbackForFailedBind(failures) }, + registerUnlockReceiver = { onUserPresent -> + registerUserPresentReceiver(context, onUserPresent) + }, // (top-level helper — a companion reference is not legal in constructor delegation) + deviceProvablyLocked = { + try { + context.getSystemService(KeyguardManager::class.java)?.isDeviceLocked == true + } catch (t: Throwable) { + false // unknowable reads as unlocked — the rollback stays available + } + } + ) + + /** Retries THIS service has attempted since the last success/foreground reset — the ladder index. */ + @Volatile + private var retriesAttempted = 0 + + /** Wall-clock ms before which [maybeRetry] stays a no-op. */ + @Volatile + private var nextRetryAtMs = 0L + + /** One receiver registration per process. */ + private val unlockReceiverArmed = AtomicBoolean(false) + + /** Single-flight: the binder's own mutex serializes passes, but don't queue on it. */ + private val retryInFlight = AtomicBoolean(false) + + /** + * The ladder-driven consultation — call freely (the bound-wallet wait + * loop calls it every 5 s poll); it no-ops unless a failed bind is + * pending AND the backoff window has elapsed. Never throws. + */ + suspend fun maybeRetry(trigger: String) { + try { + if (!bindRetryPending()) return + armUnlockReceiver() + if (now() < nextRetryAtMs) return + retryOnce(trigger) + } catch (t: Throwable) { + if (t is CancellationException) throw t + log.warn("SDK bind retry consultation failed; will retry on the next trigger", t) + } + } + + /** + * Immediate retry, bypassing the backoff window — the device-unlock + * heal path. Fire-and-forget on the injected scope; never throws. + */ + fun retryNowInBackground(trigger: String) { + scope.launch { + try { + if (!bindRetryPending()) return@launch + // The unlock is the heal condition for the false-locked + // keystore class — restart the ladder so follow-up retries + // (if this one still fails) come quickly again. + resetBackoff() + retryOnce(trigger) + } catch (t: Throwable) { + if (t is CancellationException) throw t + log.warn("immediate SDK bind retry ({}) failed; ladder retries continue", trigger, t) + } + } + } + + /** + * App came to the foreground: reset the ladder so the next wait-loop + * poll retries within seconds instead of the hourly tail. State-only — + * the actual retry rides the existing triggers. + */ + fun noteAppForeground() { + if (!bindRetryPending()) return + log.info("app foregrounded with an SDK bind retry pending — resetting the retry backoff") + resetBackoff() + } + + private fun resetBackoff() { + retriesAttempted = 0 + nextRetryAtMs = 0L + } + + /** One retry attempt + the post-attempt rollback consultation. */ + private suspend fun retryOnce(trigger: String) { + if (!retryInFlight.compareAndSet(false, true)) return + try { + // Schedule the next window BEFORE the attempt so a slow/hung + // pass cannot be stacked by the next poll. + nextRetryAtMs = now() + retryDelayMs(retriesAttempted) + retriesAttempted++ + log.info( + "SDK bind retry {} ({}): re-running the wallet bind pass " + + "({} consecutive failure(s) so far)", + retriesAttempted, trigger, consecutiveBindFailures() + ) + runBindPass() + if (!bindRetryPending()) { + log.info("SDK bind retry {} ({}) succeeded — the wallet is bound", retriesAttempted, trigger) + resetBackoff() + return + } + maybeRollBackCutover() + } finally { + retryInFlight.set(false) + } + } + + /** + * After [rollbackAfterFailures] consecutive failed passes, roll the + * committed cutover back so dashj may run — unless the device is + * provably locked (the denial is then EXPECTED and heals on unlock; + * flipping engines for it would punish every locked-screen background + * start). The coordinator no-ops from any non-CUT_OVER state, so + * repeated consultations are harmless. + */ + private suspend fun maybeRollBackCutover() { + val failures = consecutiveBindFailures() + if (failures < rollbackAfterFailures) return + if (deviceProvablyLocked()) { + log.info( + "SDK bind has failed {} consecutive passes but the device is provably locked — " + + "holding the cutover rollback; the unlock receiver retries the bind first", + failures + ) + return + } + log.warn( + "SDK bind failed {} consecutive passes with the device unlocked — rolling the " + + "cutover back so the dashj fallback engine can sync", + failures + ) + rollbackCutover(failures) + } + + /** Arm the ACTION_USER_PRESENT heal receiver (once per process). */ + private fun armUnlockReceiver() { + if (!unlockReceiverArmed.compareAndSet(false, true)) return + val registered = try { + registerUnlockReceiver { + log.info("device unlocked (ACTION_USER_PRESENT) — running an immediate SDK bind retry") + retryNowInBackground("device unlock") + } + } catch (t: Throwable) { + log.warn("failed to register the unlock-heal receiver", t) + false + } + if (!registered) unlockReceiverArmed.set(false) + } + + companion object { + private val log = LoggerFactory.getLogger(SdkBindRetryService::class.java) + + /** + * Consecutive failed bind passes before the cutover rolls back to + * dashj. With the 5/15/30/60 s ladder this is roughly two minutes + * of retrying — long enough for a transient keystore hiccup to + * clear, short enough that the user is never staring at a dead + * "Not started" for a whole session. + */ + internal const val ROLLBACK_AFTER_CONSECUTIVE_FAILURES = 5 + + } +} + +/** + * The real ACTION_USER_PRESENT registration. NOT_EXPORTED: the unlock + * broadcast is a protected system broadcast — no app-facing surface is + * exposed. The receiver stays registered for the process lifetime; once + * the wallet is bound its retries are cheap no-ops. Top-level (not a + * companion member) so the @Inject constructor's delegation expression may + * reference it. + */ +private fun registerUserPresentReceiver(context: Context, onUserPresent: () -> Unit): Boolean = + try { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action == Intent.ACTION_USER_PRESENT) onUserPresent() + } + } + ContextCompat.registerReceiver( + context, + receiver, + IntentFilter(Intent.ACTION_USER_PRESENT), + ContextCompat.RECEIVER_NOT_EXPORTED + ) + LoggerFactory.getLogger(SdkBindRetryService::class.java) + .info("unlock-heal receiver registered (ACTION_USER_PRESENT, not exported)") + true + } catch (t: Throwable) { + LoggerFactory.getLogger(SdkBindRetryService::class.java) + .warn("could not register the ACTION_USER_PRESENT receiver", t) + false + } diff --git a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkWalletBinder.kt b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkWalletBinder.kt index 3b726f3220..d1e104b12c 100644 --- a/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkWalletBinder.kt +++ b/wallet/src/de/schildbach/wallet/service/platform/sdk/SdkWalletBinder.kt @@ -26,6 +26,9 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -351,6 +354,70 @@ class SdkWalletBinder internal constructor( @Volatile private var completed = false + /** + * MO-995: whether the last bind pass that ATTEMPTED to establish the + * SDK wallet FAILED, leaving no bound wallet — i.e. a retry is owed. + * + * The field-observed outage class: the SDK's `createWallet` dies inside + * the Android keystore (`UserNotAuthenticatedException` from a + * `setUnlockedDeviceRequired` key — Keystore2 believing the device is + * locked, sometimes falsely), the SDK rolls its wallet back cleanly, and + * the binder used to stay failed FOREVER (every trigger is opportunistic + * and nothing re-armed one), while a fresh-wallet cutover commit held + * dashj — leaving the user with NO sync engine at all. + * + * True only while `boundWalletIdHex == null` after a pass that threw; + * cleared the moment any pass leaves the wallet bound. Consumed by + * [SdkBindRetryService] (backoff-capped re-invocation + the + * device-unlock heal) and surfaced by + * [de.schildbach.wallet.service.L1SyncStatusService] so the Network + * Monitor can say "wallet setup retrying" instead of the dead + * "Not started". + */ + private val _bindRetryPending = MutableStateFlow(false) + val bindRetryPending: StateFlow = _bindRetryPending.asStateFlow() + + /** + * CONSECUTIVE bind passes that attempted and failed without leaving a + * bound wallet — the rollback counter [SdkBindRetryService] consults + * before rolling a committed cutover back to dashj + * ([CutoverCoordinator.rollbackForFailedBind]). Reset to 0 by any pass + * that leaves the wallet bound. Passes SKIPPED by the eligibility gate + * (flags off, no unlock available) count neither way — they carry no + * evidence about the keystore. + */ + @Volatile + private var consecutiveBindFailuresCount = 0 + internal val consecutiveBindFailures: Int get() = consecutiveBindFailuresCount + + /** + * Record one attempted pass's outcome for the MO-995 retry machinery. + * Failed = the pass threw AND left no bound wallet; any pass that + * leaves [boundWalletIdHex] set is a success for this signal even if a + * LATER stage (discovery/key heal) failed — those have their own + * retries and do not strand the L1 engine. + */ + private fun noteBindOutcome(failed: Boolean) { + if (failed) { + consecutiveBindFailuresCount++ + _bindRetryPending.value = true + log.warn( + "SDK wallet bind still not established ({} consecutive failed pass(es)); " + + "a bind retry is pending", + consecutiveBindFailuresCount + ) + } else { + if (_bindRetryPending.value || consecutiveBindFailuresCount > 0) { + log.info( + "SDK wallet bind established after {} failed pass(es); retry pressure cleared", + consecutiveBindFailuresCount + ) + } + consecutiveBindFailuresCount = 0 + _bindRetryPending.value = false + } + } + /** * Single-flights the DIP-15 friend-chain provisioning pass. Distinct * from the bind [mutex]: provisioning does its own network I/O (a @@ -722,8 +789,18 @@ class SdkWalletBinder internal constructor( throw t } } + // MO-995: a non-throwing pass that left the wallet bound clears + // the retry pressure (a pass the eligibility gate skipped left + // the id null and changes nothing either way). + if (boundWalletIdHex != null) noteBindOutcome(failed = false) } catch (t: Throwable) { if (t is CancellationException) throw t + // MO-995: a throw with no bound wallet is the stranding failure + // class (e.g. the keystore denying the SDK's createWallet key); + // arm the retry machinery. A throw AFTER the bind established + // the wallet (discovery/heal) is not — those retry on their own + // triggers and the L1 engine is not blocked on them. + noteBindOutcome(failed = boundWalletIdHex == null) // Opportunistic by contract: never break the calling flow. log.warn("SDK wallet binding pass failed; dashj behavior unchanged", t) } diff --git a/wallet/src/de/schildbach/wallet/ui/NetworkMonitorViewModel.kt b/wallet/src/de/schildbach/wallet/ui/NetworkMonitorViewModel.kt index b839d79280..38dccfce2b 100644 --- a/wallet/src/de/schildbach/wallet/ui/NetworkMonitorViewModel.kt +++ b/wallet/src/de/schildbach/wallet/ui/NetworkMonitorViewModel.kt @@ -78,6 +78,7 @@ internal fun stageNameRes(stage: L1SyncStage): Int = when (stage) { L1SyncStage.FILTERS -> R.string.network_monitor_stage_filters L1SyncStage.SYNCED -> R.string.network_monitor_stage_synced L1SyncStage.ERROR -> R.string.network_monitor_stage_error + L1SyncStage.SETUP_RETRYING -> R.string.network_monitor_stage_setup_retrying } /** @@ -93,6 +94,10 @@ internal fun connectionStatusRes(stage: L1SyncStage): Int = when (stage) { L1SyncStage.IDLE -> R.string.network_monitor_connection_idle L1SyncStage.CONNECTING -> R.string.network_monitor_connection_searching L1SyncStage.ERROR -> R.string.network_monitor_connection_error + // MO-995: the engine is down because wallet setup failed — the + // connection row carries the actionable hint (unlocking the device is + // the heal condition for the keystore-denied bind class). + L1SyncStage.SETUP_RETRYING -> R.string.network_monitor_connection_setup_retrying else -> R.string.network_monitor_connection_connected } diff --git a/wallet/test/de/schildbach/wallet/service/L1SyncStatusServiceTest.kt b/wallet/test/de/schildbach/wallet/service/L1SyncStatusServiceTest.kt index e8b398ca3f..f268273de6 100644 --- a/wallet/test/de/schildbach/wallet/service/L1SyncStatusServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/L1SyncStatusServiceTest.kt @@ -312,6 +312,52 @@ class L1SyncStatusServiceTest { ) } + // ── MO-995: a failed wallet bind must not render as a dead "Not started" ── + + @Test + fun detail_sdkRegime_pendingBindRetryReplacesTheDeadIdleStage() { + // The Andrei readout: cutover committed, bind failed, engine never + // started — the monitor used to show "Not started"/"Network engine + // not started" forever. The pending retry now names the real state. + val detail = mergeL1SyncDetail( + sdkOwnsL1 = true, + progress = progress(phase = ShadowSyncPhase.IDLE), + sessionChainLockHeight = 0, + state = null, + bindRetryPending = true + ) + assertEquals(L1SyncStage.SETUP_RETRYING, detail.stage) + assertFalse(detail.isSynced) + } + + @Test + fun detail_sdkRegime_realScanProgressAlwaysBeatsThePendingFlag() { + // A pending flag can only coexist with a DEAD engine; if the scan is + // actually running (the wallet bound after all), the honest stage wins. + val detail = mergeL1SyncDetail( + sdkOwnsL1 = true, + progress = progress(phase = ShadowSyncPhase.FILTERS), + sessionChainLockHeight = 0, + state = null, + bindRetryPending = true + ) + assertEquals(L1SyncStage.FILTERS, detail.stage) + } + + @Test + fun detail_dashjRegime_ignoresThePendingBindRetry() { + // Post-rollback dashj is the engine: its real stages render and the + // bind retry (now background-healing) must not mask them. + val detail = mergeL1SyncDetail( + sdkOwnsL1 = false, + progress = progress(phase = ShadowSyncPhase.IDLE), + sessionChainLockHeight = 0, + state = dashjState(percentageSync = 50), + bindRetryPending = true + ) + assertEquals(L1SyncStage.HEADERS, detail.stage) + } + @Test fun merge_postCutoverSyncedPredicate_matchesTheBalanceHoldGateExactly() { // The lockstep requirement: the blinking "Syncing balance" label diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverCoordinatorTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverCoordinatorTest.kt index 56630f451e..25f87b2983 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverCoordinatorTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverCoordinatorTest.kt @@ -442,4 +442,39 @@ class CutoverCoordinatorTest { assertEquals("the UPGRADE seam is the one that wrote CUT_OVER", CutoverState.CUT_OVER.name, current) assertFalse("…but a fresh setup ran this launch, so no upgrade explainer", noticeArmed) } + + // ── MO-995: the bind-failure rollback ───────────────────────────── + + @Test + fun rollbackForFailedBind_rollsACommittedCutoverBackToDualRunning() = runBlocking { + // The Andrei outage end-state guard: the fresh-wallet commit held + // dashj, the SDK bind kept failing — the rollback must restore + // dashjEngineMayStart so the wallet is never left with NO engine. + val (coordinator, stored) = coordinator(stored = CutoverState.CUT_OVER.name) + assertFalse(coordinator.dashjEngineMayStart()) + val status = coordinator.rollbackForFailedBind(consecutiveFailures = 5) + assertEquals(CutoverState.DUAL_RUNNING, status.state) + assertEquals(CutoverState.DUAL_RUNNING.name, stored()) + assertTrue(coordinator.dashjEngineMayStart()) + } + + @Test + fun rollbackForFailedBind_isANoOpFromDualRunning() = runBlocking { + val (coordinator, stored) = coordinator(stored = CutoverState.DUAL_RUNNING.name) + val status = coordinator.rollbackForFailedBind(consecutiveFailures = 5) + assertEquals(CutoverState.DUAL_RUNNING, status.state) + assertEquals(CutoverState.DUAL_RUNNING.name, stored()) + assertTrue(coordinator.dashjEngineMayStart()) + } + + @Test + fun rollbackForFailedBind_neverRegressesSettled() = runBlocking { + // SETTLED is past the migration horizon (mirrors the state + // machine's ROLLBACK edge): the direct rollback must not regress + // it either. + val (coordinator, stored) = coordinator(stored = CutoverState.SETTLED.name) + val status = coordinator.rollbackForFailedBind(consecutiveFailures = 5) + assertEquals(CutoverState.SETTLED, status.state) + assertEquals(CutoverState.SETTLED.name, stored()) + } } diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt index 7fa815f6b2..71a4959051 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/CutoverUiDataServiceTest.kt @@ -825,7 +825,9 @@ class CutoverUiDataServiceTest { */ ownedInvolvement: suspend (String) -> Boolean? = { true }, /** Foreign-excluded store nets for the negative-event validation and contact rows. */ - walletNets: suspend (Set) -> Map = { emptyMap() } + walletNets: suspend (Set) -> Map = { emptyMap() }, + /** MO-995: the bind-retry consultation the bound-wallet wait loop drives. */ + retryBind: suspend () -> Unit = {} ) = CutoverUiDataService( source = source, dashPayConfig = dashPayConfig, @@ -848,6 +850,7 @@ class CutoverUiDataServiceTest { }, resolveOwnedInvolvement = ownedInvolvement, resolveWalletNets = walletNets, + retryBind = retryBind, nowMs = { now } ) @@ -883,6 +886,38 @@ class CutoverUiDataServiceTest { assertEquals(dashj, service.overlayTotalBalance(flowOf(dashj)).first()) } + @Test + fun postCutover_waitLoopReInvokesTheBindRetry_untilBound() = runTest { + // MO-995: the 5 s bound-wallet wait loop used to only OBSERVE the + // bound state — a single failed (keystore-denied) bind pass stranded + // it forever. It must now consult the retry machinery every poll and + // stop the moment the wallet binds. + val source = FakeSource(boundWalletId = null) + var retries = 0 + val service = buildService( + source, configWithState("CUT_OVER"), backgroundScope, + retryBind = { retries++ } + ) + service.start() + runCurrent() + assertTrue("the first poll consults the retry machinery", retries >= 1) + + testScheduler.advanceTimeBy(CutoverUiDataService.WALLET_BIND_RETRY_MS + 1) + runCurrent() + assertTrue("each poll re-consults while unbound", retries >= 2) + + // The bind heals: the loop hands off to the pipelines and stops + // consulting the retry machinery. + source.boundWalletId = "cd".repeat(32) + testScheduler.advanceTimeBy(CutoverUiDataService.WALLET_BIND_RETRY_MS + 1) + runCurrent() + val consultationsAtBind = retries + testScheduler.advanceTimeBy(CutoverUiDataService.WALLET_BIND_RETRY_MS * 3) + runCurrent() + assertEquals("no consultations once bound", consultationsAtBind, retries) + assertTrue("the pipelines actually started", source.balanceSubscriptions >= 1) + } + @Test fun postCutover_balanceServedFromSdk() = runTest { val source = FakeSource(balanceDuffs = MutableStateFlow(123_456L)) diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt new file mode 100644 index 0000000000..8a893caeaa --- /dev/null +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt @@ -0,0 +1,489 @@ +/* + * Copyright 2026 Dash Core Group. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.schildbach.wallet.service.platform.sdk + +import de.schildbach.wallet.ui.dashpay.utils.DashPayConfig +import io.mockk.coEvery +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Host-JVM tests for the MO-995 bind retry machinery: the capped backoff + * ladder, the re-arming semantics ([SdkBindRetryService.maybeRetry] / + * [SdkBindRetryService.retryNowInBackground]), the device-unlock heal + * receiver arming, and the engine-fallback rollback — including the + * end-to-end invariant over a REAL binder + REAL coordinator: after + * persistent bind failures the gate ends with dashj allowed OR the SDK + * wallet bound, never both held. + */ +class SdkBindRetryServiceTest { + + // ── The ladder ──────────────────────────────────────────────────── + + @Test + fun ladder_is5_15_30_60ThenHourly() { + assertEquals(5_000L, bindRetryDelayMs(0)) + assertEquals(15_000L, bindRetryDelayMs(1)) + assertEquals(30_000L, bindRetryDelayMs(2)) + assertEquals(60_000L, bindRetryDelayMs(3)) + assertEquals(3_600_000L, bindRetryDelayMs(4)) + assertEquals(3_600_000L, bindRetryDelayMs(99)) + } + + // ── Harness ─────────────────────────────────────────────────────── + + /** + * A scriptable failure signal standing in for the binder: [pending] / + * [failures] mirror [SdkWalletBinder.bindRetryPending] and + * [SdkWalletBinder.consecutiveBindFailures]; each [bindPass] either + * "fails" (increments both) or "succeeds" (clears both), exactly the + * binder's noteBindOutcome contract. + */ + private class FakeBinderSignal(var passSucceeds: Boolean = false) { + var pending = false + var failures = 0 + var passes = 0 + + // A pre-existing failure (the initial PlatformSyncService-triggered + // pass) is what arms the retry machinery in the first place. + fun primeFailed(initialFailures: Int = 1) { + pending = true + failures = initialFailures + } + + suspend fun bindPass() { + passes++ + if (passSucceeds) { + pending = false + failures = 0 + } else { + failures++ + pending = true + } + } + } + + private class Harness( + val signal: FakeBinderSignal = FakeBinderSignal(), + var deviceLocked: Boolean = false, + var registerSucceeds: Boolean = true + ) { + var nowMs = 0L + var rollbacks = 0 + var lastRollbackFailures = -1 + var registrations = 0 + var unlockCallback: (() -> Unit)? = null + + fun service(scope: kotlinx.coroutines.CoroutineScope) = SdkBindRetryService( + scope = scope, + bindRetryPending = { signal.pending }, + consecutiveBindFailures = { signal.failures }, + runBindPass = { signal.bindPass() }, + rollbackCutover = { failures -> + rollbacks++ + lastRollbackFailures = failures + }, + registerUnlockReceiver = { onUserPresent -> + if (registerSucceeds) { + registrations++ + unlockCallback = onUserPresent + } + registerSucceeds + }, + deviceProvablyLocked = { deviceLocked }, + now = { nowMs } + ) + } + + // ── maybeRetry: gating + ladder ─────────────────────────────────── + + @Test + fun maybeRetry_isANoOpWhileNoFailureIsPending() = runTest { + val h = Harness() + val service = h.service(backgroundScope) + service.maybeRetry("poll") + assertEquals(0, h.signal.passes) + assertEquals(0, h.registrations) // receiver only arms once a failure exists + } + + @Test + fun maybeRetry_retriesImmediatelyOnTheFirstConsult_thenHonorsTheLadder() = runTest { + val h = Harness() + h.signal.primeFailed() + val service = h.service(backgroundScope) + + // First consult: window 0 → retry runs (and fails again). + service.maybeRetry("poll") + assertEquals(1, h.signal.passes) + + // Same 5 s poll cadence, but inside the 5 s window → no attempt. + h.nowMs += 4_999 + service.maybeRetry("poll") + assertEquals(1, h.signal.passes) + + // Window elapsed → second retry. + h.nowMs += 2 + service.maybeRetry("poll") + assertEquals(2, h.signal.passes) + + // The second retry armed the 15 s rung. + h.nowMs += 5_001 + service.maybeRetry("poll") + assertEquals(2, h.signal.passes) + h.nowMs += 10_000 + service.maybeRetry("poll") + assertEquals(3, h.signal.passes) + } + + @Test + fun maybeRetry_successResetsTheLadder() = runTest { + val h = Harness() + h.signal.primeFailed() + val service = h.service(backgroundScope) + + service.maybeRetry("poll") // fails, arms the 5s rung + h.signal.passSucceeds = true + h.nowMs += 5_001 + service.maybeRetry("poll") // succeeds — pending clears + assertEquals(2, h.signal.passes) + assertFalse(h.signal.pending) + + // Bound: later consults are no-ops. + h.nowMs += 100_000 + service.maybeRetry("poll") + assertEquals(2, h.signal.passes) + assertEquals(0, h.rollbacks) + } + + @Test + fun noteAppForeground_collapsesTheBackoffWindow() = runTest { + val h = Harness() + h.signal.primeFailed() + val service = h.service(backgroundScope) + + // Climb to the hourly tail: 5 retries. + repeat(5) { + h.nowMs += 3_600_000 + service.maybeRetry("poll") + } + assertEquals(5, h.signal.passes) + + // Deep inside the hourly window nothing fires… + h.nowMs += 60_000 + service.maybeRetry("poll") + assertEquals(5, h.signal.passes) + + // …until the app foregrounds, which resets the ladder. + service.noteAppForeground() + service.maybeRetry("poll") + assertEquals(6, h.signal.passes) + } + + // ── The unlock heal receiver ────────────────────────────────────── + + @Test + fun unlockReceiver_armsOnceAndHealsWithAnImmediateRetry() = runTest { + val h = Harness() + h.signal.primeFailed() + val service = h.service(backgroundScope) + + service.maybeRetry("poll") + h.nowMs += 5_001 + service.maybeRetry("poll") + assertEquals(1, h.registrations) // armed exactly once + assertEquals(2, h.signal.passes) + + // The device unlock is the heal condition: the keystore stops + // denying, and the receiver-triggered retry bypasses the backoff. + h.signal.passSucceeds = true + checkNotNull(h.unlockCallback).invoke() + runCurrent() + assertEquals(3, h.signal.passes) + assertFalse(h.signal.pending) + assertEquals(0, h.rollbacks) + } + + @Test + fun unlockReceiver_reArmsOnALaterConsult_whenRegistrationFailed() = runTest { + val h = Harness(registerSucceeds = false) + h.signal.primeFailed() + val service = h.service(backgroundScope) + + service.maybeRetry("poll") + assertEquals(0, h.registrations) + + h.registerSucceeds = true + h.nowMs += 5_001 + service.maybeRetry("poll") + assertEquals(1, h.registrations) + } + + // ── The engine-fallback rollback ────────────────────────────────── + + @Test + fun rollback_firesAfterTheFailureThreshold_withTheDeviceUnlocked() = runTest { + val h = Harness() + h.signal.primeFailed() + val service = h.service(backgroundScope) + + // Failures 2..4 (initial + three retries): below the threshold. + repeat(3) { + h.nowMs += 3_600_000 + service.maybeRetry("poll") + } + assertEquals(0, h.rollbacks) + + // The 5th consecutive failure crosses it. + h.nowMs += 3_600_000 + service.maybeRetry("poll") + assertEquals(1, h.rollbacks) + assertEquals(5, h.lastRollbackFailures) + } + + @Test + fun rollback_isHeldWhileTheDeviceIsProvablyLocked() = runTest { + // A genuinely locked device EXPECTS keystore denials; flipping + // engines for it would punish every locked-screen background start. + val h = Harness(deviceLocked = true) + h.signal.primeFailed() + val service = h.service(backgroundScope) + + repeat(8) { + h.nowMs += 3_600_000 + service.maybeRetry("poll") + } + assertTrue(h.signal.failures >= SdkBindRetryService.ROLLBACK_AFTER_CONSECUTIVE_FAILURES) + assertEquals(0, h.rollbacks) + + // Unlock: the receiver retry heals instead — no rollback needed. + h.deviceLocked = false + h.signal.passSucceeds = true + checkNotNull(h.unlockCallback).invoke() + runCurrent() + assertFalse(h.signal.pending) + assertEquals(0, h.rollbacks) + } + + // ── End-to-end invariant: dashj allowed OR sdk bound, never both held ── + + /** + * The full MO-995 outage replayed over a REAL [SdkWalletBinder] and a + * REAL [CutoverCoordinator]: fresh-wallet commit holds dashj, the SDK + * bind (createWallet in the keystore) fails on every pass, the retry + * service drives the ladder — and the gate MUST end with + * `dashjEngineMayStart() == true`. Before this fix the end state was + * dashj held forever with nothing bound: no sync engine at all. + */ + @Test + fun endToEnd_persistentBindFailure_endsWithDashjAllowed_neverBothHeld() = runTest { + // Real coordinator over a stateful in-memory CUTOVER_STATE. + var storedState: String? = null + val config = mockk() + coEvery { config.get(DashPayConfig.CUTOVER_STATE) } answers { storedState } + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_L1_SHADOW) } returns true + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_DPNS_READS) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_DASHPAY_WRITES) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_SHIELDED) } returns false + coEvery { config.set(DashPayConfig.CUTOVER_STATE, any()) } answers { + storedState = secondArg() + Unit + } + val collector = mockk() + val coordinator = CutoverCoordinator(config, collector) + + // Real binder whose SDK bind dies in the keystore, forever. + val sdk = mockk(relaxed = true) + coEvery { sdk.bindAppWallet(any(), any()) } throws + IllegalStateException("Keystore createWallet: UserNotAuthenticatedException") + val identityConfig = mockk { + coEvery { loadBase() } returns de.schildbach.wallet.database.entity.BlockchainIdentityBaseData( + creationState = de.schildbach.wallet.database.entity.IdentityCreationState.NONE, + creationStateErrorMessage = null, + username = null, + usernameSecondary = null, + userId = null, + restoring = false + ) + } + val walletData = mockk { + io.mockk.every { wallet } returns null + } + val serviceConfig = mockk { + coEvery { getWalletCreationDate() } returns null + } + val binder = SdkWalletBinder( + sdkService = sdk, + mnemonicProvider = object : PlatformMnemonicProvider { + override suspend fun getMnemonicWords(unlock: WalletUnlock) = + listOf("abandon", "abandon", "about") + }, + identityConfig = identityConfig, + dashPayConfig = config, + walletData = walletData, + blockchainServiceConfig = serviceConfig, + scope = backgroundScope, + supportsPlatform = { true }, + backfillGate = DashPayBackfillGate.ALWAYS_RUN + ) + + // The Andrei launch: fresh-wallet setup commits the cutover… + assertEquals(CutoverState.CUT_OVER, coordinator.commitForFreshWalletSetup().state) + assertFalse(coordinator.dashjEngineMayStart()) // dashj held + + // …then the first bind pass fails (the keystore denial). + binder.bindIfEnabled { WalletUnlock.Unencrypted } + assertTrue(binder.bindRetryPending.value) + + // The retry service drives the ladder to the rollback threshold. + var nowMs = 0L + val retryService = SdkBindRetryService( + scope = backgroundScope, + bindRetryPending = { binder.bindRetryPending.value }, + consecutiveBindFailures = { binder.consecutiveBindFailures }, + runBindPass = { binder.bindIfEnabled { WalletUnlock.Unencrypted } }, + rollbackCutover = { failures -> coordinator.rollbackForFailedBind(failures) }, + registerUnlockReceiver = { true }, + deviceProvablyLocked = { false }, + now = { nowMs } + ) + repeat(SdkBindRetryService.ROLLBACK_AFTER_CONSECUTIVE_FAILURES) { + nowMs += 3_600_000 + retryService.maybeRetry("poll") + } + + // THE INVARIANT: the gate rolled back — dashj may sync again. + assertEquals(CutoverState.DUAL_RUNNING.name, storedState) + assertTrue(coordinator.dashjEngineMayStart()) + } + + /** The complementary end state: the bind HEALS — the cutover stays committed (SDK owns L1). */ + @Test + fun endToEnd_bindHealsBeforeTheThreshold_cutoverStaysCommitted() = runTest { + val h = Harness() + h.signal.primeFailed() + var rolledBack = false + var nowMs = 0L + val service = SdkBindRetryService( + scope = backgroundScope, + bindRetryPending = { h.signal.pending }, + consecutiveBindFailures = { h.signal.failures }, + runBindPass = { h.signal.bindPass() }, + rollbackCutover = { rolledBack = true }, + registerUnlockReceiver = { true }, + deviceProvablyLocked = { false }, + now = { nowMs } + ) + service.maybeRetry("poll") // failure 2 + nowMs += 5_001 + service.maybeRetry("poll") // failure 3 + h.signal.passSucceeds = true + nowMs += 15_001 + service.maybeRetry("poll") // heals on the third retry + assertFalse(h.signal.pending) + assertFalse(rolledBack) + } + + // ── The binder's own outcome bookkeeping (real binder) ──────────── + + @Test + fun binder_flagsThePendingRetry_andClearsItOnSuccess() = runBlocking { + val config = mockk() + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_DPNS_READS) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_DASHPAY_WRITES) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_SHIELDED) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_L1_SHADOW) } returns true + coEvery { config.get(DashPayConfig.SDK_GAP_WIDENED_VERSION) } returns + SdkWalletBinder.GAP_WIDEN_HEAL_VERSION + var bindFails = true + val sdk = mockk(relaxed = true) + coEvery { sdk.bindAppWallet(any(), any()) } answers { + if (bindFails) throw IllegalStateException("keystore denied") else "ab".repeat(32) + } + coEvery { sdk.loadedWalletIds() } returns emptySet() + val identityConfig = mockk { + coEvery { loadBase() } returns de.schildbach.wallet.database.entity.BlockchainIdentityBaseData( + creationState = de.schildbach.wallet.database.entity.IdentityCreationState.NONE, + creationStateErrorMessage = null, + username = null, + usernameSecondary = null, + userId = null, + restoring = false + ) + } + val binder = SdkWalletBinder( + sdkService = sdk, + mnemonicProvider = object : PlatformMnemonicProvider { + override suspend fun getMnemonicWords(unlock: WalletUnlock) = + listOf("abandon", "abandon", "about") + }, + identityConfig = identityConfig, + dashPayConfig = config, + walletData = mockk { io.mockk.every { wallet } returns null }, + blockchainServiceConfig = mockk { coEvery { getWalletCreationDate() } returns null }, + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + supportsPlatform = { true }, + backfillGate = DashPayBackfillGate.ALWAYS_RUN + ) + + // Two failed passes: pending + a climbing consecutive count. + binder.bindIfEnabled { WalletUnlock.Unencrypted } + assertTrue(binder.bindRetryPending.value) + assertEquals(1, binder.consecutiveBindFailures) + binder.bindIfEnabled { WalletUnlock.Unencrypted } + assertEquals(2, binder.consecutiveBindFailures) + + // The keystore heals (the post-unlock retry): everything clears. + bindFails = false + binder.bindIfEnabled { WalletUnlock.Unencrypted } + assertFalse(binder.bindRetryPending.value) + assertEquals(0, binder.consecutiveBindFailures) + } + + @Test + fun binder_skippedPass_neverArmsTheRetry() = runBlocking { + // All flags off → the eligibility gate skips before touching the + // SDK; a skip carries no keystore evidence and must not arm. + val config = mockk() + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_DPNS_READS) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_DASHPAY_WRITES) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_SHIELDED) } returns false + coEvery { config.get(DashPayConfig.USE_KOTLIN_SDK_L1_SHADOW) } returns false + val binder = SdkWalletBinder( + sdkService = mockk(relaxed = true), + mnemonicProvider = object : PlatformMnemonicProvider { + override suspend fun getMnemonicWords(unlock: WalletUnlock) = error("must not run") + }, + identityConfig = mockk(), + dashPayConfig = config, + walletData = mockk { io.mockk.every { wallet } returns null }, + blockchainServiceConfig = mockk(), + scope = kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Unconfined), + supportsPlatform = { true }, + backfillGate = DashPayBackfillGate.ALWAYS_RUN + ) + binder.bindIfEnabled { WalletUnlock.Unencrypted } + assertFalse(binder.bindRetryPending.value) + assertEquals(0, binder.consecutiveBindFailures) + } +} diff --git a/wallet/test/de/schildbach/wallet/ui/NetworkMonitorViewModelTest.kt b/wallet/test/de/schildbach/wallet/ui/NetworkMonitorViewModelTest.kt index b46693a442..e2d2911885 100644 --- a/wallet/test/de/schildbach/wallet/ui/NetworkMonitorViewModelTest.kt +++ b/wallet/test/de/schildbach/wallet/ui/NetworkMonitorViewModelTest.kt @@ -133,6 +133,12 @@ class NetworkMonitorViewModelTest { buildNetworkMonitorUiState(detail(stage = active), false).connectionRes ) } + // MO-995: a bind-failure retry gets the actionable unlock hint, not + // the dead "Network engine not started". + assertEquals( + R.string.network_monitor_connection_setup_retrying, + buildNetworkMonitorUiState(detail(stage = L1SyncStage.SETUP_RETRYING), false).connectionRes + ) } @Test From 4e4f6c398c97cac7b9524178141905fee4746091 Mon Sep 17 00:00:00 2001 From: HashEngineering Date: Thu, 27 Aug 2026 08:34:18 -0700 Subject: [PATCH 2/2] fix: correct off by one error in retries in test Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../wallet/service/platform/sdk/SdkBindRetryServiceTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt index 8a893caeaa..ee49dcb8fa 100644 --- a/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt +++ b/wallet/test/de/schildbach/wallet/service/platform/sdk/SdkBindRetryServiceTest.kt @@ -367,7 +367,7 @@ class SdkBindRetryServiceTest { deviceProvablyLocked = { false }, now = { nowMs } ) - repeat(SdkBindRetryService.ROLLBACK_AFTER_CONSECUTIVE_FAILURES) { + repeat(SdkBindRetryService.ROLLBACK_AFTER_CONSECUTIVE_FAILURES - 1) { nowMs += 3_600_000 retryService.maybeRetry("poll") }