Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions wallet/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
<string name="network_monitor_stage_filters">Scanning block filters</string>
<string name="network_monitor_stage_synced">Synced</string>
<string name="network_monitor_stage_error">Sync error</string>
<string name="network_monitor_stage_setup_retrying">Wallet setup incomplete — retrying</string>
<string name="network_monitor_progress">%1$d%%</string>
<string name="network_monitor_headers_label">Block headers</string>
<string name="network_monitor_filters_label">Block filters</string>
Expand All @@ -210,6 +211,7 @@
<string name="network_monitor_connection_searching">Searching for peers…</string>
<string name="network_monitor_connection_idle">Network engine not started</string>
<string name="network_monitor_connection_error">Connection error</string>
<string name="network_monitor_connection_setup_retrying">Unlock your device to finish wallet setup</string>
<string name="network_monitor_peer_details_note">The wallet engine does not report individual peer connections.</string>
<string name="network_monitor_dashj_hint">Peer and block lists come from the dashj diagnostic engine. Turn on dashj sync in Tools to view them.</string>
<string name="block_row_mining_difficulty_adjustment">Mining difficulty adjustment</string>
Expand Down
60 changes: 60 additions & 0 deletions wallet/src/de/schildbach/wallet/service/BlockchainServiceImpl.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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

/**
Expand Down Expand Up @@ -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)
}
}
}
}
}

/**
Expand Down Expand Up @@ -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) {
Expand Down
39 changes: 33 additions & 6 deletions wallet/src/de/schildbach/wallet/service/L1SyncStatusService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -409,6 +431,7 @@ class L1SyncStatusService @Inject constructor(
l1ShadowSyncService: L1ShadowSyncService,
blockchainStateProvider: BlockchainStateProvider,
dashPaySyncStatus: DashPaySyncStatus,
sdkWalletBinder: SdkWalletBinder,
scope: CoroutineScope
) {
/**
Expand Down Expand Up @@ -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<L1SyncDetail> =
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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,16 @@ class CutoverUiDataService internal constructor(
* snapshot-only tests.
*/
private val loadPersistedInstantLocks: suspend (Collection<String>) -> Set<String> = { 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,
Expand Down Expand Up @@ -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,
Expand All @@ -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() },
Expand Down Expand Up @@ -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
Expand All @@ -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
)
}
Expand Down
Loading
Loading