Skip to content

fix(voting): MO-977 — real vote errors, already-cast votes, and dark-mode rows - #1566

Open
HashEngineering wants to merge 209 commits into
fix/sync-process-stallsfrom
fix/username-voting-already-cast
Open

HashEngineering wants to merge 209 commits into
fix/sync-process-stallsfrom
fix/username-voting-already-cast

Conversation

@HashEngineering

@HashEngineering HashEngineering commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Three defects on the Username Voting screen (MO-977). The first two are in BroadcastUsernameVotesWorker and interact — the second masked the first — so they are fixed together; the third is the dark-mode rendering Andrei reported on 12000014.

Stacked on #1564 (fix/contested-username-restore-identity), so the diff here is only the voting work. Retarget to master once #1564 merges.

1. The failure path itself threw, hiding every vote error

Both the all-failed and the partial-failure branches built KEY_LABELS as arrayOfnames.map { labelMap[it] } — a List. androidx.work.Data accepts String[] only, so every attempt to report a vote error died before returning:

java.lang.IllegalArgumentException: Key BroadcastUsernameVotesWorker.LABELS
  has invalid type class java.util.ArrayList
    at androidx.work.Data$Builder.put(Data.java:923)
    at BroadcastUsernameVotesWorker.doWorkWithBaseProgress

The user saw that instead of the real error. Firebase logged it as the reason on username_voting_details_btn_vote_fail; UsernameRequestsFragment logged "error processing vote information". The success branch was already correct — it passes the String[] straight through from inputData.

.toTypedArray() alone would not have fixed it: labelMap[it] is a nullable lookup, and Data rejects Array<String?> for the same reason.

Nullability decision: a name with no display label falls back to the normalized name, rather than being filtered out. UsernameRequestsFragment reads KEY_LABELS and KEY_NORMALIZED_LABELS as parallel arrays (showVoteIndicator(votes, usernames, …) indexes into both), so dropping an element would desync them — and the normalized name is still truthful to show.

There was a second half to this. A failed vote carries no Vote and so no poll name, and the name array was substituting the literal string "null" for it — which is precisely the key labelMap had no entry for. Those are now dropped, falling back to the submitted labels when nothing is left.

2. An already-cast vote was unwrapped as a hard failure

E/BroadcastUsernameVotesWorker: all votes failed: errors: 1 vs total submitted 1
java.lang.Exception: Attempted to unwrap a Failure: Protocol error:
  Masternode vote is already present for masternode DghTta8E4ySZsozAoF4WjnY
    at org.dashj.platform.sdk.base.Result$Failure.unwrap(Result.java:51)

"Vote is already present" means the masternode has already voted this poll — the desired end state is already true. Failures are now classified, only terminal ones count against the broadcast, and an already-cast vote reconciles as success and logs at info instead of error.

The vote-budget error the worker's comments have long anticipated alongside it (already voted 5 times … they can only vote 5 times) stays terminal and is deliberately not collapsed into the same verdict. Since it also contains the words "already voted", the limit marker is matched first — reversing those two branches would report a spent vote budget as a successful vote.

Shape

classifyVoteFailure, voteFailureText and labelsFor are pure top-level functions, host-JVM testable without WorkManager or the SDK — the same pattern as isOwnContestedCandidate / contestedNameCandidates in RestoreIdentityWorker, tested in the style of SecondaryNameCollisionTest. voteFailureText flattens the cause chain (cycle-safe), because the field exception arrives wrapped in Attempted to unwrap a Failure: ….

Both verbatim engine messages are pinned in VoteFailureClassificationTest, along with an assertion that the markers actually occur in the messages they claim to match, so an SDK reword fails loudly rather than silently reclassifying a duplicate vote as fatal.

Not addressed here

An already-cast vote still writes no UsernameVote row: the failed Triple<ResourceVoteChoice, Vote?, Exception?> carries the vote choice but not the poll name. The success path's updateUsernameRequestWithVotes refresh reconciles the true count from the network anyway; recording it locally would mean widening PlatformBroadcastService.broadcastUsernameVotes's return type, which is wider than these two bugs.

3. Expanded contested-name rows were white in dark mode

Reported on the ticket against 12000014: the voting list renders correctly in dark mode until a contested name with multiple requests is expanded — each child row then draws as a solid white bar with no visible text.

username_request_view.xml backs its row with @drawable/selectable_round_corners_border_light, whose fill was a hardcoded @android:color/white. The text on that row uses theme-aware styles (@style/Overline), so in dark mode it turns white and vanishes against the white fill. The group row above it is fine because it uses @drawable/rounded_background with ?attr/backgroundColor.

The fill now follows the theme. background_secondary resolves to palette_white (#FFFFFF) by day — byte-identical to the @android:color/white it replaces, so light mode cannot regress — and to palette_black_800 at night, matching the group card the rows sit inside. That is the same relationship light mode already had, with the 1dp stroke providing the separation in both themes. The stroke and ripple are left alone: button_ripple_light is a 25%-alpha gray (#40B0B6BC) that composites correctly over either background.

The drawable has exactly one usage — the row being fixed — so there is no collateral. Its _light name is now a misnomer, kept to hold the diff to the defect; a comment in the file records why the fill must stay theme-aware.

Out of scope, noted while hunting this: an app-wide audit found ~39 other uses of light-only colors on backgrounds/text across roughly 30 layouts (dash_gray, gray_200, dash_deep_blue, fg_less_significant, …). None are on the voting screens. Worth its own ticket.

Testing

  • ./gradlew :wallet:compile_testNet3DebugKotlin — BUILD SUCCESSFUL
  • ./gradlew :wallet:test_testNet3DebugUnitTest — 2009 tests, 0 failures (1996 baseline + 13 new)
  • ./gradlew ktlintCheck — clean
  • ./gradlew :wallet:assembleProdDebug — BUILD SUCCESSFUL; installed on an emulator in dark mode for the fix in §3

Note: CI build already fails on the base branch with Unresolved reference 'startWalletSubsystems' (L1ShadowSyncService.kt) — the base branch calls a Kotlin SDK API newer than the dashSdkVersion its commits pin. Pre-existing and unrelated to this change.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Rounded selectable controls now use the appropriate background color in light and dark themes.
    • Username voting results now handle votes that were already cast without treating them as failures. Failed votes display readable labels and clearer error messages, including when some votes succeed and others fail.

HashEngineering and others added 30 commits September 10, 2026 09:30
…rocess (MO-995, MO-998)

Post-cutover the SDK's L1 (SPV) engine went down on a routine
BlockchainServiceImpl teardown and never came back for the rest of the
process. Field symptoms on the 12.0.0 QA builds: incoming transactions
never arrived (two Coinbase deposits invisible for 2h10m on mainnet), the
sync header sat at "syncing", and Buy Credits failed with "pre-broadcast:
SPV client not started".

platformSyncService.resume() — the only in-process path that re-kicks the
SDK engines, and whose own comment says every service (re)start must call
it — lived solely inside checkService()'s peergroup-start block, BELOW the
`!dashjEngineMayStart` early return. Post-cutover that return always
fires, so resume() was unreachable and the engines only ever started from
PlatformSyncService.init(), which runs once per process from
WalletApplication.finalizeInitialization(). Any teardown that stopped them
— release-build shutdown() -> stopSdkEngines(), reached from
onTrimMemory's low-memory stopSelf(), the idle detector, or the Android 15
FGS timeout — therefore killed L1 sync permanently.

It was a latch, not a transient: the idle detector samples this same
engine's progress post-cutover, so a dead engine reads as zero activity
and kept tearing the service down once a minute. Debug builds hid it
entirely, because shutdown() deliberately keeps the engines warm there.

Call resume() from the post-cutover branch of onCreate — beside the
SDK-quorum wiring, which already exists to compensate for checkService()
never proceeding once the cutover is committed. resume() is idempotent
(single-flight bind, idempotent startIfEnabled), so the first service
start of a process harmlessly re-kicks what init() started. Scoped to
`!dashjEngineMayStart`, so the pre-cutover path keeps its original
resume()-at-peergroup-start behavior byte for byte.

Regression test: PlatformSyncEngineRestartTest, the mirror of
PlatformSyncEngineTeardownTest. The onCreate call site itself is not
host-testable, so the tests lock down the contract the fix depends on —
resume() brings the engines back after a teardown, repeatedly, including
after the full shutdown() path that cancels the sync scope's children.
That last case is mutation-verified: swapping shutdown()'s
cancelChildren() for syncJob.cancel() fails it and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…O-995)

Tapping the local-currency option on Enter Amount To Transfer crashed the app:

  java.lang.IndexOutOfBoundsException: setSpan (12 ... 7) has end before start
    at EnterAmountToTransferFragment.spanAmount(EnterAmountToTransferFragment.kt:185)
    at EnterAmountToTransferFragment.formatTransferredAmount(...:172)

Three times in Andrei's 12.0.0 mainnet log (13:30:11, 13:31:47, 13:34:03),
each a foreground process death.

The fiat currency-last branch bounded the small-text/colour span with
`viewModel.inputValue.length`. The fiat branch of `applyNewValue` sets
`fiatBalance = inputValue` only when the input has at most two decimals;
otherwise `fiatBalance` is the 2-dp formatted figure, which is shorter.
Switching DASH -> fiat feeds `formatInput` — an exchange-rate-converted value
with many decimals — through the same path, so `from` (12) overshot `to` (7)
and Spannable.setSpan threw. The currency-first branch above it already used
`fiatBalance` and even guarded the subtraction, so only this branch was wrong.

Use `fiatBalance` as the boundary, matching the text `applyNewValue` actually
returned ("$fiatBalance $symbol").

The deeper hazard is that all three branches take their bounds from separate
mutable ViewModel fields while `applyNewValue` refreshes only the one belonging
to the branch it took (`formattedValue` for DASH, `fiatBalance` for fiat), and
setSpan throws on a bad range rather than ignoring it. So the computation moves
out of the fragment into a pure `amountCurrencySpan()` that returns null for an
empty or out-of-range result: a future field mismatch loses the styling instead
of the screen.

Tests: AmountCurrencySpanTest pins the bounds for each of the three branches
plus the degrade-to-null cases, including the crash's own numbers. Verified by
mutation — dropping the range check fails 3 tests, and reverting the boundary
to another field fails 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QA's walletB upgraded to 12.0.0-sync, ran fine on dashj all morning (fully
synced at 08:38), then at 18:20 lost sync entirely: "network is not synced",
"setup is incomplete". It ended the 11.5-hour log with ZERO L1 engines and no
`L1Shadow phase=` line anywhere.

Two independent defects in the UPGRADE cutover seam, both fixed here.

GATE 1 — the version-code test now gates the COMMIT, not just the explainer.

`commitForUpgradedWalletAsync` committed unconditionally and only consulted
`previousVersionCode` afterwards, to decide whether to arm the one-time sync
explainer. walletB reached the seam on a SAME-VERSION relaunch (previous code
12000001 on a 12000001 build) — not an upgrade across the cutover boundary at
all. The code even logged that its own precondition did not hold and committed
regardless. A launch that did not cross the boundary has no business handing
over L1; the readiness-gated auto-commit observer owns that decision. Extracted
as the pure, host-testable `isPreCutoverUpgrade`.

The crossing is LATCHED (`CUTOVER_UPGRADE_BOUNDARY_CROSSED`) because
`Configuration.lastVersionCode` is "the version the PREVIOUS LAUNCH ran", not
"the version this install upgraded from" — `updateLastVersionCode` overwrites it
every startup. So the crossing is visible for exactly one launch, and that is
the one launch where GATE 2 cannot yet pass. Without the latch these two gates
could never both hold and the seam would never commit again, taking the
one-time sync explainer with it.

GATE 2 — never hand L1 to an SDK that has never bound on this install.

Committing HOLDS the dashj engine, so committing while the bind is broken
leaves no engine at all. walletC and walletD (both clean upgrades) show the
commit runs 3 and 7 seconds BEFORE the first bind is even attempted — they
succeeded on luck, not design. walletB took the identical path with 16
consecutive `KeystoreDeviceLockedException` denials on the lock-bound
`org.dashfoundation.wallet.master` alias, and the designed escape hatch
(`rollbackForFailedBind`) never fired because it was withheld pending an
`ACTION_USER_PRESENT` that never arrived.

The seam now requires durable evidence — `SDK_BIND_EVER_SUCCEEDED`, set by
`SdkWalletBinder` on the first successful bind. Absent or false reads as
never-bound (fail safe, not fail open).

Net effect: a healthy upgrade latches on launch 1 and commits on launch 2 (one
launch of delay, invisible — dashj is already serving a synced wallet);
walletB latches and never commits, staying on dashj instead of being left with
no engine.

Deliberately NOT a deferred/awaited commit: `BlockchainServiceImpl` resolves
its engine gate once at service onCreate and `onCutoverStateChanged` is
un-hold-only, so a commit landing mid-launch cannot stop a live dashj
peergroup — the "never two live SPV engines" invariant the existing
rollback-over-defer comment protects. Declining outright keeps dashj as the
single engine for the launch; once a bind succeeds, the seam (or auto-commit)
takes it from there.

Two pre-existing tests asserted "the commit itself must be unaffected by the
notice gate"; that is precisely the behaviour that was wrong, so they now
assert no-commit. Seven new cases cover both gates, the latch's two-launch
sequence, and the boundary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…st a new one (MO-995)

Found by running the cutover scenarios on the emulator: after a clean reset,
launch 2 still logged "the SDK wallet bind has never succeeded on this install"
and declined the commit, while the L1 engine was demonstrably running. The
persisted state confirmed it — `cutover_upgrade_boundary_crossed` was there,
`sdk_bind_ever_succeeded` was absent entirely.

Cause: the marker was written inside `sdkService.bindAppWallet(...).also { }`,
which only runs when a NEW SDK wallet is created. Any launch that found the
wallet already bound took the "app wallet already bound" path and never wrote
it — so on those devices the upgrade seam declined forever and the cutover
could never commit. The real-upgrade run passed only because it happened to be
a first-ever bind.

Moved to `noteBindOutcome(failed = false)`, the canonical success signal, whose
contract is already "any pass that leaves boundWalletIdHex set is a success".

Also in this commit:

- CutoverCoordinatorTest fixtures now DERIVE from
  `CutoverCoordinator.FIRST_CUTOVER_VERSION_CODE` instead of hardcoding a
  release line. The constant moved from 11100000 to 12000000 (the cutover ships
  in 12.0.0), which silently turned the hardcoded 11100100 "already cut over"
  fixture into a *pre*-cutover value and inverted what two tests asserted.

- scripts/cutover-emulator-test.sh: the emulator harness for the four
  scenarios. Three harness bugs were fixed while getting it trustworthy, all of
  which produced false FAILs on healthy code:
    * `since_mark | grep -q` under `set -o pipefail` — grep -q exits on first
      match, tail dies of SIGPIPE, the pipeline reports 141 and a MATCH looks
      like a failure. Only bit patterns appearing early in a large window,
      which is why later assertions passed and earlier ones did not. Windows
      are now written to a file and grepped there, never through a pipe.
    * log truncation was unreliable, so a stale line from an earlier run
      satisfied a refute. Assertions are now scoped to lines added since a
      mark, with a rotation guard.
    * `pull_log` fell back to `run-as`, impossible on a release build (not
      debuggable), silently leaving an EMPTY file. It now retries with root and
      keeps the last good copy rather than clobbering it with nothing.
  Plus `show_state`/`require_state` so a contaminated precondition aborts
  loudly instead of reporting a meaningless failure.

Emulator results with these fixes, on a real 11.9.1 -> 12.0.0-sync upgrade:
  S1 launch 1  declined + latched, no commit            PASS
  S1 launch 2  committed + explainer armed              PASS (real-upgrade run)
  S4           trim -> teardown -> engine restarted     PASS

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three produced misleading PASS/FAIL on healthy code:

- `wake_unlock` led with `keyevent 26` (power), which TOGGLES: on an
  already-unlocked device it turned the screen OFF and re-locked it, silently
  re-creating the Keystore device-locked denial inside scenarios that need a
  WORKING bind. S1 failed twice for this reason alone. Now wakes with keyevent
  224 only, and warns instead of pretending when a keyguard is still up.
  (`input text` does not reach the keyguard PIN pad either — digits need
  keyevents 8-11 — so an actually-locked device must be unlocked by hand.)

- S3 never called `mark_log`, so `LOG_MARK` stayed 0 and its assertions matched
  the ENTIRE log history. It reported PASS for in-session recovery purely off
  lines from an hour earlier.

- Added `keep_awake` (svc power stayon + 30min screen_off_timeout): scenarios
  poll for up to 45s, and an idle screen-off re-locks the device mid-run.

Verified results with these fixes, on a real 11.9.1 -> 12.0.0-sync upgrade:
  S1  launch 1 declines+latches, launch 2 commits+arms explainer   5/5 PASS
  S2  real Keystore -72 denial: declines, dashj live, not engine-less  5/5 PASS
  S4  trim -> teardown -> engine restarts                          PASS
  S3  BLOCKED: needs a manual unlock between S2 and S3

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The bind-evidence gate lived only in `commitForUpgradedWalletAsync`, which left
the readiness-driven path wide open. Reproduced on the emulator: with every
bind failing on a real `KeystoreDeviceLockedException`, the upgrade seam
correctly declined and `CutoverAutoCommitObserver` committed anyway — FOUR
times, at 16:46:23, 17:57:52, 18:08:33 and 18:13:16:

    cutover state DUAL_RUNNING -> READY_OBSERVED on OBSERVE_READINESS (ready=true)
    cutover state READY_OBSERVED -> CUT_OVER on COMMIT_CUTOVER (ready=true)
    cutover auto-commit: SDK is now L1-primary (dashj held); observer standing down

That reaches walletB's engine-less end state through a different door. The
readiness evaluator has no notion of whether the wallet is bound, so the check
has to sit where the write happens — and there are TWO write sites:
`writeState` (via commitLocked) and `transition` (auto-commit / manual commit).

`refusesCutOverWithoutBindEvidence` is now consulted by both, so the seam, the
fresh-wallet commit and auto-commit all inherit one implementation. Only
CUT_OVER is guarded: ROLLBACK and the wipe reset move AWAY from a committed
state and must never be blocked — that is the escape hatch.

This also closes the clean-install exposure flagged earlier: the fresh-wallet
commit had no bind gate at all, so a new wallet on a denying-Keystore device
would commit, hold dashj, fail to bind, and have nothing but the rollback to
save it. It now stays on dashj instead.

Two existing tests needed their premise stated rather than assumed:
- `commitForFreshWalletSetupAsync_commitsOnTheInjectedScope` is about WHICH
  SCOPE runs the commit, so it now supplies the evidence it isn't testing.
- `endToEnd_persistentBindFailure_endsWithDashjAllowed_neverBothHeld` models a
  wallet that bound successfully and whose Keystore later started denying, so
  the commit is legal there and the rollback is the safety net under test. A
  never-bound wallet is refused up front instead.

Three new cases; mutation-verified — removing the `transition()` guard fails
`autoAdvance_refusesToCommit_whenTheSdkBindHasNeverSucceeded` and nothing else.
36 tests in CutoverCoordinatorTest, full :wallet suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`input text` does not reach the keyguard PIN pad, so every scripted unlock
silently failed and left the device locked — which then denied the master-alias
keystore op inside scenarios that need a WORKING bind. S1 failed twice and S3
was unrunnable for this reason.

Enter the PIN with digit keyevents instead (KEYCODE_0 is 7, so digit d -> 7+d),
after a swipe to raise the PIN pad, and verify the keyguard is actually down
afterwards rather than assuming it. Verified on device: lock -> script unlock ->
keyguard down.

With this, S2 -> S3 runs unattended: S2 locks to force a real Keystore denial,
S3 unlocks to test whether recovery happens without an app restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`noteAppForeground()` was state-only — it reset the retry ladder and left the
actual retry to "the existing triggers". There is exactly one such trigger:
`CutoverUiDataService.awaitBoundWallet()`, the sole caller of `maybeRetry`, and
that loop runs only while the cutover holds dashj with NO bound wallet.

Once the coordinator correctly refuses to commit onto an unbindable SDK
(717d27df9), that state never occurs. So the loop never runs, `maybeRetry` is
never called, the unlock receiver is never armed, and nothing ever retries. The
retry machinery was built to rescue the engine-less state; removing that state
removed its only trigger.

Reproduced on the emulator (scenario S3), all with the device unlocked:
  - after a real Keystore denial, unlock + foreground produced ZERO
    SdkWalletBinder / SdkBindRetryService lines
  - "unlock-heal receiver registered" appeared 0 times in the whole session
  - only a full app restart healed it (app wallet already bound…)

which is walletB's shape exactly: it too logged ACTION_USER_PRESENT zero times
across ten hours while Andrei repeatedly opened the app.

So foregrounding now arms the receiver AND drives a pass via the existing
`retryNowInBackground`, which resets the ladder, runs one bind pass, and
consults the rollback. App-foreground is the right trigger because it depends
on neither the cutover state nor a broadcast: the user is looking at the app, so
the device is provably unlocked — the heal condition for a device-locked
keystore denial — and nothing has to survive an OEM's background restrictions
(walletB is a HONOR PTP-N49; MagicOS suppresses exactly that broadcast class).

`noteAppForeground_collapsesTheBackoffWindow` asserted the OLD contract — that
foregrounding runs no pass — so it is renamed and now asserts the new one. Two
new cases pin the gap itself: recovery with no polling trigger at all, and the
receiver being armed by a foreground visit. Mutation-verified — reverting to
state-only fails all three.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…O-995)

`BlockchainServiceImpl` observed `ProcessLifecycleOwner` for foreground/
background transitions. It has never fired once.

`wallet/AndroidManifest.xml` removes `androidx.startup.InitializationProvider`
with `tools:node="remove"` — a deliberate cold-start optimisation (that provider
runs before Application.onCreate on every launch, and removing it also
suppresses WorkManager's default initializer). But that provider is what runs
`lifecycle-process`'s `ProcessLifecycleInitializer`, which is the only thing
wiring `ProcessLifecycleOwner` to activity transitions. Without it the process
owner never leaves INITIALIZED, so onStart/onStop never fire — silently, no
error. Confirmed with aapt2: the provider is absent from the built APK.

Field evidence: across a 27,000-line log with 36 service onCreate()s, "App moved
to foreground" and "App moved to background" appear ZERO times. Consequences,
both pre-existing:

  - `isAppInBackground` was frozen at its initial value for every reader
  - `SdkBindRetryService.noteAppForeground()` had never once been called, so
    8cd47b19b's foreground bind-retry was attached to a dead hook — and the
    older state-only version was dead code too

Together with ACTION_USER_PRESENT never arriving on MagicOS, that is why
walletB had zero working recovery triggers across ten hours while Andrei
repeatedly opened the app.

Fix keeps the manifest optimisation and takes the same edges from
`WalletActivityTracker`, which is registered the plain way
(`registerActivityLifecycleCallbacks`, WalletApplication:384), demonstrably
works, and whose `ActivitiesTracker` base already computes the first-started /
last-stopped edges. New `AppForegroundMonitor` exposes them as a StateFlow; the
service collects it. No androidx.startup, no effect on any other library's
initializer.

Not yet device-verified: needs a signed install, then S2 -> S3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…MO-995)

a3dd2904b hung the app-foreground signal off ActivitiesTracker's
onStartedAny/onStoppedLast hooks. Those never run: WalletActivityTracker
overrides onActivityStarted/onActivityStopped WITHOUT calling super, so the
base class's numStarted counter never advances and neither onStartedFirst,
onStartedAny nor onStoppedLast ever fires. Verified on device — the new build
was confirmed installed (AppForegroundMonitor present in classes3.dex) and
"App moved to foreground" still logged zero times.

Derive it from visibleActivityCount instead, the counter this class maintains
itself and which demonstrably tracks reality (its logState() output is all over
the field logs as "visible: N foreground: N").

Note for follow-up: onStoppedLast being dead means
`autoLogout.setAppWentBackground(true)` and the zero-minute force-finish
broadcast in that method never run either. Not touched here — separate concern,
and auto-logout has its own paths.

Still not device-verified: needs a signed install, then S2 -> S3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… out (MO-995)

S3's two failing assertions were both wrong in the same way — they looked for
log lines that structurally cannot appear in that scenario's window:

- "committed in-session" wanted `DUAL_RUNNING -> CUT_OVER` within 45s. But the
  upgrade seam only runs at process start, so in-session the only route is the
  readiness-gated auto-commit, which needs MIN_PARITY_STREAK readings at a 10s
  throttle AND the SDK scan caught up to tip. A missing commit there is correct
  deferral, not a defect. Moved to a new s3b step that checks the NEXT launch.

- "dashj still owns L1" wanted `dashjEngineMayStart=` or `starting peergroup`.
  Those are logged at service onCreate, and S3 deliberately keeps ONE process
  alive — that is the whole point of the scenario — so no onCreate occurs.
  Replaced with `assert_not_committed`, which reads the persisted DataStore
  state instead of the log.

S3 now asserts the actual recovery chain, each link of which was broken until
b49ef25b0: the foreground signal fires, it drives a retry, the retry runs a
pass, the bind heals, retry pressure clears, and no process restart occurred.

Verified on device, all green:
  S2   5/5  denial -> gates decline -> dashj live -> not engine-less
  S3   7/7  foreground -> retry -> bind heals IN THE SAME PROCESS
  S3b  2/2  next launch commits -> SDK L1 engine starts

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…995)

The device lock PIN was committed as a literal in the harness. It now comes
from the EMULATOR_PIN environment variable with no default, and the unlock
helper fails loudly with instructions rather than silently typing an empty PIN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`onStoppedLast()` has never run. It is reached only from
`ActivitiesTracker.onActivityStopped`, and this class overrides
onActivityStarted/onActivityStopped WITHOUT calling super, so the base's
`numStarted` never advances and none of its hooks fire. Not a recent
regression: the anonymous tracker this class replaced (WalletApplication,
pre-fae81fefb) had the identical defect, so `autoLogout.setAppWentBackground
(true)` and the zero-minute force-finish broadcast have been dead for a long
time.

Found while tracking down why the app-foreground signal never fired (b49ef25b0
/ 2125a73) — same root cause, different victim.

Call `onStoppedLast()` explicitly from the branch that does run, next to the
AppForegroundMonitor edge.

DELIBERATELY NOT FIXED IN THE SAME WAY: `onStartedAny` is equally dead, and it
forces a full app restart on the first activity start after any upgrade. Its
stated purpose (pushing v6.x installs through the PIN upgrade) is long
obsolete, and blanket-adding `super` would switch that on for every 12.x
upgrade during the cutover release. Left dormant with a comment saying so, to
be enabled on purpose and tested, or deleted along with `myPackageReplaced`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QA saw "Received DASH -0.0x" after a gift-card purchase. The producer is
`BlockchainServiceImpl.notifyCoinsReceived` — the SDK announcer
(`CutoverUiDataService`) formats a value it only ever gates positive, so it
cannot render a negative amount.

Post-cutover every send is authored by the SDK and then committed into the
dashj wallet-of-record by `SdkBridgedTransactionFactory` (for the send UI, not
for notifications — the BIP70 site at SendCoinsTaskRunner:1075 is the gift-card
path and is NOT DEBUG-gated). bitcoinj's `maybeCommitTx` raises
`onCoinsReceived` whenever `getValueSentToMe() > 0`, and dashj post-cutover has
never synced the funding txs, so the inputs are unconnected: it sees the
+change output alone, values the send positive, and delivers it to the received
listener. `onCoinsSent` does not fire at all.

The guard against that was `correctedNetValue()`, and it was resolved TWICE:
once on the wallet-listener thread for the announced amount (correctly negative)
and again inside `passFilters` — which runs inside `handler.post`, i.e. on the
MAIN thread, where Room throws (no `allowMainThreadQueries`, DatabaseModule:41)
and the lookup fails soft to dashj's positive misread. Gate said "received",
amount said "-0.0x". Deterministic, not a race, which is why it reproduced.

Fix, primary: a transaction this wallet authored is a send however dashj values
it. The bridge stamps `purpose = USER_PAYMENT` and `confidence.source = SELF`
before the commit, so both are present the instant the listener fires — unlike
the tx_display_cache row, which does not exist yet in the window between the
bridge commit and the SDK's display-row insert.

Fix, secondary: resolve the corrected net ONCE, on the listener thread, and
thread it into passFilters, so the gate and the announced value are the same
number by construction. Plus a final belt in notifyCoinsReceived refusing any
non-positive amount.

Pre-cutover this changes nothing: a dashj-authored send is valued negative and
already failed the sign test, and a genuine receive is neither self-authored nor
negative. Out-of-band receives (direct payment / Bluetooth `receivePending`)
still notify — which is why this is a self-authored guard and not a blanket
`!dashjEngineMayStart` gate on the listener.

NOT FIXED: `handleContactPayments` is reached only from the received path, so a
bridged send still feeds `updateFrequentContacts`, and the CrowdNode matchers
are still evaluated before the guard (inert — they key on the CrowdNode
address). The structural version is one early return at the top of the listener;
deliberately left out of this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(MO-995)

QA saw an ordinary 0.0002 DASH send labelled "CoinJoin Mixing" on BOTH the
sending and receiving wallet, while the coinjoin balance was 0 for the whole
session — no mixing had occurred. 0.0002 DASH is not a CoinJoin denomination,
so amount-matching does not explain it.

The label comes from CoinJoinMixingTxSet.tryInclude, which groups a transaction
as CoinJoin unless dashj's shape heuristic CoinJoinTransactionType.fromTx
returns None or Send — so ANY of CreateDenomination / MakeCollateralInputs /
MixingFee / Mixing / CombineDust pulls it in. That heuristic is evaluated
against the DASHJ wallet, which post-cutover is held with its balance frozen at
the cutover snapshot, while the transaction itself originates from the SDK. A
stale wallet is being asked to classify a new transaction, and fromTx depends
on wallet context (which inputs are mine, input values, denomination and
collateral matching). That is the suspect; nothing currently logs the verdict,
so this adds it — bounded to transactions actually being grouped as CoinJoin,
not one line per list row.

Also fixes a latent crash found while reading the path: `tx.raw as Transaction`
was unconditional, but `TxInfo.raw` is `Any?` and is documented as an OPAQUE
handle. Post-cutover a TxInfo can be built from the SDK rather than dashj, so a
non-dashj payload — or null — threw a ClassCastException out of transaction-list
grouping. A payload the heuristic cannot read is not a transaction we can call
CoinJoin: exclude it, log it, keep the list alive.

Tests: three cases pinning the payload contract; mutation-verified — restoring
the unchecked cast fails both crash tests. The misclassification itself cannot
be unit-tested (fromTx needs real wallet/UTXO context and there is no WalletEx
fixture), which is exactly why the diagnostic exists; the next field report
settles which of the five types it returned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…metadata

QA hit this on a release build:

    java.lang.ClassCastException: DaggerWalletApplication_HiltComponents_SingletonC
    $FragmentCImpl cannot be cast to UpholdPortalFragment_GeneratedInjector
        at Hilt_UpholdPortalFragment.inject
        at Hilt_UpholdPortalFragment.onAttach

`integrations/uphold` was the ONLY module in the project setting
`minifyEnabled true` on its own library output. R8 strips the entire
`hilt_aggregated_deps/` package from the library's release artifacts —
measured 5 entries in debug, 0 in release, including
`_org_dash_wallet_integrations_uphold_ui_UpholdPortalFragment_GeneratedInjector`.
That package is how the APP module's `hiltAggregateDeps` task discovers
@androidentrypoint classes in library modules. Without it the generated
component never implements the interface. The interface itself survives (it
lives in a package the existing `-keep class org.dash.wallet.integrations.uphold.**`
rule protects), which is why the failure is a ClassCastException rather than a
missing class.

Measured on the app's generated component, before -> after:

    _testNet3Debug     uphold=2  total=154   (unchanged)
    _testNet3Release   uphold=0  total=152  ->  uphold=2  total=154

Removing it costs nothing: the app module already sets `minifyEnabled true`
(wallet/build.gradle, release), so these classes are still shrunk and
obfuscated in the final APK — once, at the app level, with the whole program
visible. Minifying a library separately only removes metadata its consumers
still need. `consumerProguardFiles` already ships this module's keep rules to
the app, which is where they belong.

NOT a regression from this branch: `minifyEnabled true` is present on master
too (added in #934), so master release builds carry the same defect. Most
likely nobody had opened the Uphold portal on a master release build. Worth
confirming with QA — if it reproduces there, it has been shipping broken.

Verification note for anyone re-checking this: partial task invocations
mislead. `aar_main_jar` (what the app compiles against) needs the library's own
release rebuild, and the app component is written by
`hiltJavaCompile_<variant>` — which neither `hiltAggregateDeps_<variant>` nor
`ksp_<variant>Kotlin` runs, even with --rerun-tasks. Only a full
`assemble_<variant>` regenerates it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QA screenshot: "Synced / Block headers 2,532,259 / Block filters — /
Masternode list height 2,532,250 / ChainLock height 2,532,258". Every row held
its value except filters.

`mergeL1SyncDetail` already has a restart-sawtooth guard: the engine reports
all-zero heights through IDLE/CONNECTING for minutes after a process restart,
so percentage, headers, mnlist and chainlock are all backstopped rather than
collapsing to zero. Filters were taken raw:

    percentage      = if (idleOrConnecting) state?.percentageSync ?: 0 else ...
    headerHeight    = maxOf(progress.headerHeight, state?.bestChainHeight ?: 0)
    mnListHeight    = maxOf(progress.mnListHeight, state?.mnlistHeight ?: 0)
    chainLockHeight = maxOf(sessionChainLockHeight, state?.chainlockHeight ?: 0)
    filterHeight    = progress.filterHeight      <-- no guard
    filterTarget    = progress.filterTarget      <-- no guard

and `NetworkMonitorActivity.formatHeights` renders "-" exactly when height AND
target are both <= 0. The field log carries the matching state:
`L1Shadow phase=IDLE 0.0% headers 0/0 filters 0/0`.

BlockchainState persists no filter height — dashj has no filter pipeline, so
the row never carries one — so the backstop is the last non-zero pair seen in
this process, held in L1SyncStatusService and passed into the (still pure)
merge function. On a cold start with no reading yet, "-" remains the honest
answer.

Scoped to IDLE/CONNECTING deliberately. Outside that window a filter height
moving BACKWARDS is real: the DashPay coreHeight backfill legitimately rewinds
the scan (observed dropping 1,543,144 -> 1,252,305 in the field), and a blanket
max() would mask it and report a position the engine no longer holds. There is
a test for that case.

Cosmetic only — no sync behaviour changes. Three tests; mutation-verified,
reverting to raw filters fails the idle case and nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Andrei, MO-995 comment 91138 #2: "After upgrade finished and network was
resync (I assume it happened exactly 24 hours later), I received again"
— the one-time upgrade sync explainer, whose own copy says "This happens
only once, after this update".

From the field log (2026-09-02, prod, 11.9.1 -> 12.0.0-sync):

  07:31:54  PACKAGE_UPDATED — the upgrade launch
  07:31:56  declining to commit the cutover: the SDK wallet bind has
            never succeeded on this install
  07:32:10  cutover auto-commit observer started
            ... 8h29m of silence, shadow at phase=SYNCED 100.0%,
                parity "first sustained MATCH streak complete" ...
  17:31:54  cutover state DUAL_RUNNING -> CUT_OVER (upgraded-wallet launch)
  17:31:54  upgrade cutover: one-time sync explainer armed

The notice was already pending at 07:31 and the user acknowledged it;
the seam then armed it a second time ten hours later.

CUTOVER_UPGRADE_NOTICE_PENDING cannot guard against this: the sheet sets
it back to false on acknowledgment, which an arming site cannot tell
apart from never-armed. And the arming site is reachable more than once
per install — GATE 1 passes on the durable CUTOVER_UPGRADE_BOUNDARY_CROSSED
latch, so any rollbackForFailedBind -> re-commit cycle arms it again.

So add CUTOVER_UPGRADE_NOTICE_EVER_ARMED, a marker that is never
cleared, and route the arming through armUpgradeNoticeOnce(). The commit
itself is untouched — only the explainer is suppressed. An unreadable
latch suppresses too: a missed explainer is not the user-visible defect.

Why the commit lands that late is structural, not a glitch: the
bind-evidence gate cannot pass on the upgrade launch (the bind runs
after the seam), so the commit always defers to a later process start.
Bounding that would mean changing which engine owns L1 mid-launch, which
the "never two live SPV engines" invariant forbids. Left as-is; this
makes the deferral harmless to the user instead.

Also: CutoverAutoCommitObserver is the in-launch path that would have
closed the ten-hour gap, and it ran 8.5 hours emitting exactly one line.
Both of its non-committing paths returned early in silence, so the log
cannot say whether the stability gate never armed or the readiness policy
kept refusing. Add a 5-minute-throttled "still waiting: <reason>" line so
the next field log answers that.

Tests: 3 added to CutoverCoordinatorTest (39 total, all green), each
mutation-verified — dropping the latch check, swapping the write order,
and inverting the unreadable-latch fallback each fail exactly one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`startIfEnabled`/`stop` tear down and relaunch four long-lived loops —
progress monitor, parity probe, watchdog, wallet-event tap — and nothing
recorded how long the engine was actually down between them.

That cost real time on MO-995. In the 2026-09-02 field log:

  10:38:00  BlockchainServiceImpl - idling detected, stopping service
  10:38:00  L1Shadow progress monitor cancelled
  10:38:00  L1 shadow sync stopped
            ... 5h28m, no line from anything ...
  16:06:27  L1Shadow phase=IDLE 0.0% headers 0/0 filters 0/0

`progress` is a StateFlow fed by the cancelled monitor, so it froze at its
last value and its consumers simply stopped being called.
CutoverAutoCommitObserver was armed and waiting that entire span and
emitted nothing — which is why the cutover fell through to the next
process start, ten hours after the upgrade. Establishing that meant
diffing two timestamps 12,000 log lines apart.

So report the gap on the start that ends it. A stop is only a problem if
nothing restarts, and "nothing restarted" has no log line by construction
— the thing that would log it is the thing that did not run. Measuring on
the NEXT start needs no timer and no watchdog:

  L1ShadowLifecycle STOPPED after 3h1m up; all four loops torn down ...
  L1ShadowLifecycle RESUMING after 5h28m down (teardown #3 this process)

WARN past LONG_ENGINE_DOWNTIME_MS (10 min) because a routine idle-detector
bounce is seconds to minutes — the three real teardowns in that log were
~26s, ~3min and 5h28m, and a WARN on every bounce is a WARN nobody reads.

Deliberately narrow: instrumentation only, no behaviour change. The
underlying fragility (one watchdog restart per PROCESS, while the shadow
stops and starts several times per process) is left alone — and this
outlives the dashj retirement (MO-992), where these loops stop being a
debug shadow's and become THE engine's, so an unnoticed teardown goes
from a lost readiness signal to a wallet with no L1 at all.

Tests: 2 added, both mutation-verified — dropping the minutes from the
hours format and raising the threshold past the field outage each fail
exactly one. Full :wallet suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…O-995)

Both from CodeRabbit on PR #1555.

1. The harness printed FAIL and exited 0. `assert_log`, `refute_log` and
   `assert_not_committed` each end in a `printf`, so the function returns
   its status — 0 — and an automated run could call a failed cutover
   scenario a success. Added a FAILURES ledger and an EXIT trap.

   NOT an `exit 1` at the failing assertion, as suggested: each scenario is
   a SEQUENCE of assertions and the diagnostic value is in seeing all of
   them. S3 asserting "bind failed" then "dashj came back" then "state not
   committed" tells you WHERE the chain broke; aborting at the first FAIL
   reports only that it broke. Assertions still print and continue; only
   the exit status changes.

2. CodeRabbit also asked to revert FIRST_CUTOVER_VERSION_CODE from
   12000000 back to 11100000. Not doing that — 12000000 is correct, the
   cutover ships in 12.0.0 (wallet/build.gradle versionName 12.0.0), and
   reverting would classify every 11.10–11.25 build as already cut over
   and silently deny those users the one-time sync explainer.

   But the inconsistency it detected was real, and one instance was a bug:
   `fake_pre_cutover_previous_launch` hardcoded its own copy of the
   boundary (`-lt 11100000`). A device last run on 11.10–11.25 — which the
   APP counts as pre-cutover — was judged post-cutover by the script, so it
   overwrote a genuine `last_version` with a faked one, defeating the
   "leave the truth alone" branch the surrounding comment describes. The
   script now reads the boundary from one variable documented to track the
   constant.

   The rest was stale prose: KDoc and comments named "11.10" in eight
   places after the boundary moved to 12.0. Version numbers are now stated
   only where the constant is defined — prose that names a release goes
   stale silently, and this boundary has already moved once. Also dropped
   an orphaned KDoc block left over from rederiving a test fixture.

No behaviour change in the app; the only executable change is the script.
Full :wallet suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…995)

QA on 12000003: "one time sync not started". A brand-new wallet synced on
dashj instead of the SDK, so the SDK's fast initial sync never ran.

My own bug, from 36792cc ("refuse EVERY path into CUT_OVER without SDK
bind evidence"). I put that guard inside commitLocked, which the
FRESH-WALLET commit shares with the upgrade seam. On a fresh install
there is no bind evidence BY CONSTRUCTION — the commit is what routes the
launch, and the first bind pass only runs once platform sync starts,
after it. So the guard could never pass there.

Field log (2026-09-03, prod 12.0.0-sync/12000003, new wallet) — the bind
succeeding three seconds AFTER the refusal is the whole bug in one line:

  07:10:02  successfully created new wallet
  09:29:06  declining to commit the cutover: the SDK wallet bind has
            never succeeded on this install
  09:29:06  Phase 5d cutover gate: dashjEngineMayStart=true
  09:29:09  app wallet bound to new SDK wallet d992760a…

The original author had documented exactly this, in rollbackForFailedBind:
"the fresh-wallet commit CANNOT wait for the first successful bind,
because the commit IS what routes the fresh-wallet launch ... So the
commit stays immediate and THIS is the escape hatch." I read that KDoc,
then made "every path" literally every path anyway.

So commitLocked now takes requireBindEvidence explicitly — true for the
upgrade seam, false for fresh-wallet setup. No default: every call site
states its intent. A failing bind on a fresh wallet is handled the way it
was designed to be, by rollbackForFailedBind (wired at
SdkBindRetryService:133), which restores dashj. Deferring instead cannot
work: a deferred commit lands mid-launch with the dashj peergroup already
up, i.e. two live SPV engines.

The guard is UNCHANGED on the paths where it can be satisfied — the
upgrade seam (walletB's engine-less HONOR state) and the readiness
auto-commit. The refusal log now names which path refused; not knowing
that cost time reading this report.

Tests: the old freshWalletCommit_refusesWithoutBindEvidence asserted the
regression, so it is replaced by a pin on the corrected behaviour, plus a
new test holding both halves of the asymmetry together. Mutation-verified
in both directions: restoring the guard on the fresh path fails exactly
the new pin, and disabling it everywhere fails 6 tests. Full :wallet
suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QA reported "crash on openning". It actually fired while the app sat
idle, 12 hours before the wallet was next opened — the exit reason on the
following launch is what they saw the aftermath of:

  PROCESS EXIT REASON: CRASH(4) — previous process died 12h 6m ago

The crash, 2026-09-02 18:48:44, prod 12.0.0-sync:

  18:48:44  idling detected, stopping service
  18:48:44  .onStartCommand(Intent { … (has extras) })
  18:48:44  .onDestroy()
  18:48:44  onStartCommand waiting for onCreate to complete...
  18:48:44  CrashReporter - crashing because of uncaught exception
  android.app.RemoteServiceException$ForegroundServiceDidNotStartInTimeException:
    Context.startForegroundService() did not then call Service.startForeground()

A start delivered by startForegroundService() arms a system promise: call
startForeground() within a few seconds or the process is killed. That
call sat INSIDE onStartCommand's serviceScope.launch, behind
onCreateCompleted.await() — so it was satisfied only after the service's
entire async init, and not at all if the service was being torn down
instead. Here an AlarmManager PendingIntent.getForegroundService
(WalletApplication:1689, rescheduleService) fired just as the idle
detector was stopping the service, and the two interleaved.

Fixed by calling it synchronously in onStartCommand, before the
coroutine. Safe: Android guarantees onCreate() has RETURNED before
onStartCommand() runs, and Hilt injects notificationService in
super.onCreate() (line 1833), so the notification can be built. The
onCreateCompleted latch is about this service's own async init, which the
FGS deadline does not wait for — the two were conflated.

Note on scope: ea506f9 (mine, earlier in this branch) added SDK
engine-start work to onCreate, which lengthens async init and so widens
this window. The defect predates it, but that change likely made it more
reachable.

The condition is extracted as the pure carriesForegroundStartPromise() so
it is testable at all — the bug was a MISSED call, and that is only
testable if the decision to make it is separable from making it; the
service lifecycle is not reachable from a host-JVM test. Kept narrow so
the fix cannot over-promote: an ordinary startService() start, absent
extras, and a null-intent system redelivery must all stay out of the
foreground. Mutation-verified both ways — promoting unconditionally fails
3 tests, never promoting fails 1.

Not addressed: the underlying stopSelf()/onStartCommand race. Satisfying
the promise makes it survivable, but the idle detector can still stop a
service that a start command has just asked for. stopSelfResult(startId)
is the usual answer and is a behaviour change, so it is left separate.

Full :wallet suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…1022)

QA on 12000004, both devices: "I still don't get dialog after upgrade,
that one time resync is needed." It was never armed at all — not late,
never.

Two facts combined. The upgrade seam cannot commit on the upgrade launch
(GATE 2 wants bind evidence and the bind lands seconds later), so it
returned before reaching the arming. The commit then happened on the
READINESS auto-commit path — which had no arming logic whatsoever.

Field log (2026-09-04, prod 12000004, SM-A536B), the whole story:

  17:10:59  declining to commit (upgraded-wallet launch): bind has never succeeded
  17:11:02  app wallet bound to new SDK wallet a60ed232…
  18:06:02  cutover state DUAL_RUNNING -> READY_OBSERVED on OBSERVE_READINESS
  18:06:02  cutover state READY_OBSERVED -> CUT_OVER on COMMIT_CUTOVER
  18:06:02  cutover auto-commit: SDK is now L1-primary (dashj held)

Committed, and no explainer. So the arming belongs to the COMMIT, not to
one particular caller: armUpgradeNoticeIfUpgraded() is now called from
both commitLocked and transition, gated on the durable
CUTOVER_UPGRADE_BOUNDARY_CROSSED latch (so a fresh install is never told
its wallet was upgraded) and on the existing once-ever latch. The
fresh-wallet suppression moved in with it, since that path shares
commitLocked.

That also means my previous commit (210e50c, "don't show the notice
twice") was fixing a double-arm on a path that in the normal upgrade flow
never fires. The once-ever latch it added is still what keeps this
idempotent now that three paths can arm.

Same log also answers the question left open in 3e978df's message —
why the auto-commit observer never committed. It commits fine; in the
earlier report it was starved because the idle detector had torn the
shadow down. Here it took 55 minutes of waiting and then worked.

Every log line in this area now names the committing path, which is what
made this diagnosable at all.

Tests: 2 added — the auto-commit path must arm, and must NOT arm on an
install that never crossed the boundary. Mutation-verified: removing the
arming from transition fails exactly the first. Also made two test mocks
faithful — noticeCoordinator's evidence collector was unstubbed (so the
readiness path could not be driven through it), and the boundary latch
was a constant-false stub where production writes it before committing.
Full :wallet suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…022)

QA on 12000004: "Block filters / ChainLock Height are shown as '-'",
after I had already claimed this fixed in 786ebea. That fix had a hole:
its backstop is the last non-zero filter pair seen IN THIS PROCESS, which
is empty on a cold start — exactly when the Network Monitor gets opened.

Field log (2026-09-04, prod 12000004, SM-A536B). App opened 19:13:07,
Network Monitor checked immediately:

  19:13:14  L1Shadow phase=IDLE 0.0% headers 0/0 filters 0/0 wallet 2533349
  19:14:14  L1Shadow phase=SYNCED 100.0% headers 2533366/2533366 filters 2533366/2533366

A full minute of "-" before the engine reconnects.

Note `wallet 2533349` on the very line where filters read 0/0. That is
the engine's committed wallet cursor, seeded at shadow start from the
SDK's DURABLE watermark (L1ShadowSyncService.startIfEnabled →
sdkWalletSyncedHeight), so unlike lastKnownFilter* it survives a process
restart. It is not a proxy: the committed wallet cursor IS the
filter-scan position — that is why blockPipelineLagging measures the scan
with it. So it becomes the cold-start floor. My comment in 786ebea
asserted "nothing persists a filter height"; that was simply wrong, and
the value was already on the progress object being read.

The target takes the same floors, so the pair can never render as
height > target. Deliberately not the persisted header tip: that would
report a target the filter pipeline never received.

ChainLock: NOT changed. Andrei's phrasing groups it with filters, but the
evidence says it renders — BlockchainStateDataProvider already max-guards
chainlockHeight (a 0 never clobbers the row), clearSdkDerivedState does
not touch it, and his own earlier screenshot (MO-995 comment 91138) shows
ChainLock at 2,532,258 while filters read "—". Changing it would mean
inventing a consensus height from the scan cursor, which would be a false
claim. Needs his screenshot to confirm before touching.

Tests: 1 added for the cold-start floor, asserting the pair invariant too.
Mutation-verified. The existing "nothing known yet → - is honest" test
still holds: with no cursor either, "-" remains correct.
Full :wallet suite green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
QA: Buy Credits failed because the cutover was not committed. It wasn't
committed because the SDK L1 scan could never reach the tip, and it could
never reach the tip because the engine was killed on every backgrounding.

  override fun onTrimMemory(level: Int) {
      if (level >= TRIM_MEMORY_BACKGROUND) {          // 40
          log.warn("low memory detected, stopping service")
          stopSelf()

TRIM_MEMORY_BACKGROUND does not mean low memory. It means "your process
has gone onto the LRU background list" — the user left the app. And the
signals that DO mean running-low, TRIM_MEMORY_RUNNING_LOW (10) and
RUNNING_CRITICAL (15), are numerically BELOW 40, so they were ignored
entirely. The scale is not ordered by severity, so `>=` cannot express
the policy at all: the test was wrong in both directions and the log line
misreported what it had detected.

Field log (2026-09-06, testnet 12000004, HONOR PTP-N49, 384/512MB heap) —
the ordinary backgrounding sequence, two seconds apart:

  10:47:24  onTrimMemory(20) called          <- UI hidden
  10:47:26  onTrimMemory(40) called          <- on the LRU list
  10:47:26  low memory detected, stopping service
  10:47:26  .onDestroy()
  10:47:50  onDestroy() cleanup is taking longer than 5 seconds
  10:48:42  low memory detected, stopping service

That wallet had upgraded from 11.9.1-scan and resolved birthHeight to 0
(birth time 2018-08-09 -> checkpoint 3456, minus a 4032 margin, clamped),
so it faced a from-genesis scan of 1,548,486 testnet blocks — about four
hours at the observed rate of 31k filters per five minutes. It never got
four uninterrupted minutes: three engine restarts in the five logged.
scanCaughtUpToTip never held, the observer sat at "still waiting: streak
0/5", and Buy Credits had no SDK-owned L1.

Now stops only on the two levels that mean imminent death:
TRIM_MEMORY_COMPLETE (80, top of the LRU kill list) and
RUNNING_CRITICAL (15, the system is already killing background
processes). MODERATE (60) is deliberately excluded — mid-LRU is not
imminent danger, and a long L1 scan is exactly the workload that has to
ride it out.

Related but distinct from ea506f9 earlier in this branch: that made the
engine RESTART after a teardown, which is why the scan resumes rather
than dying outright. It did not stop the spurious teardown, and for an
hours-long scan the thrash is nearly as costly.

Tests: the predicate is extracted as a pure Int function and every
documented level is pinned, because the numbers are not ordered by
severity and a future `>=` rewrite must fail loudly. Mutation-verified:
restoring `>= TRIM_MEMORY_BACKGROUND` fails 4 of 5, including the
stops-on-critical case. Full :wallet suite green.

Not addressed: the four-hour from-genesis scan itself, and the >5s
onDestroy cleanup that compounds each restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
grpc-netty-shaded's Utils.isEpollAvailable() looks up
io.grpc.netty.shaded.io.netty.channel.epoll.Epoll.isAvailable() via
reflection. R8 kept the class but stripped the method, so the probe threw
NoSuchMethodException instead of returning false, and NettyChannelBuilder's
static initializer died with ExceptionInInitializerError the first time
PlatformHealthProbe constructed a DAPIGrpcMasternode in a release build.

Keep the shaded epoll package so the probe returns false on Android and
grpc falls back to NIO as it already does in debug builds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Username creation crash-loops on release builds. 11 crashes on a HONOR
PTP-N49 in one hour, every one identical:

  java.lang.ExceptionInInitializerError
    at DAPIGrpcMasternode.<init>(DAPIGrpcMasternode.kt:52)
    at PlatformHealthProbe.fetchDapiNodeCoreHeight(PlatformHealthService.kt:129)
  Caused by: IllegalArgumentException: Class NioSocketChannel does not have
    a public non-arg constructor
    at NettyChannelBuilder.<clinit>(NettyChannelBuilder.java:84)
  Caused by: NoSuchMethodException: ...channel.socket.nio.NioSocketChannel.<init>

NettyChannelBuilder's static initializer wires its transport entirely by
REFLECTION, so R8 sees no reference to the members it needs and strips
them. Each strip becomes an ExceptionInInitializerError that kills the
process the first time anything constructs a DAPIGrpcMasternode. Two were
hit in consecutive release builds:

  1. 12000005: Utils.isEpollAvailable() does Class.forName(...epoll.Epoll)
     .getDeclaredMethod("isAvailable") — class kept, method stripped, so
     the probe threw instead of returning false. Fixed by 7c2aa22.
  2. 12000006: with epoll now correctly false, <clinit> falls through to
     `new ReflectiveChannelFactory<>(NioSocketChannel.class)` on the NEXT
     line, which does getConstructor() — and that no-arg constructor had
     been stripped.

Targeted rules are the wrong shape: each one only reveals the next
stripped member, and the set depends on the netty build, the device (epoll
vs NIO) and the grpc version. So keep the package wholesale.

Cost, measured on the _testNet3 release APK: 26,542 methods / 1.9 MB of
dex for io.grpc.netty.shaded, against a 233 MB APK — under 1%, and the app
is already multiDex. Cheaper than a crash loop.

Verified by building the release variant and reading the dex back rather
than by reasoning about R8:

  apkanalyzer dex code --class ...NioSocketChannel  -> .method public constructor <init>()V
  apkanalyzer dex code --class ...epoll.Epoll       -> .method public static isAvailable()Z

Both members now survive. No unit tests: R8 does not run for the JVM test
variant, so a release build is the only thing that can check this.

This one crash accounts for all three QA reports: the username-creation
crash, the username-creation FAILURE (CreateUsernameActivity recreated
after each death, so the flow never completes), and MO-973's "lock screen
appears after clicking Continue" — Continue navigates to
RequestUsernameFragment, whose onViewCreated fires checkNetworkHealth(),
and the lock screen is Android relaunching the killed process. Seven
times: activity created -> crash 3-4s later -> WalletApplication.onCreate
in the same second -> show lock screen true -> fingerprint prompt.

NOT the real fix. Netty is present only because the legacy Java
dapi-client uses it for transport; the Kotlin SDK does not. Also reachable
from TopUpRepository.getTransaction, IdentityRepository's credit balance,
and PlatformSyncService.reportNetworkStatus — which is why this is a keep
rule and not just a fix to the health probe. Still to do: catch Throwable
in probe() (a static-initializer Error escapes its `catch (Exception)`
today), and retire the legacy client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
probe() caught only Exception. The legacy gRPC stack fails with an ERROR:
ExceptionInInitializerError out of NettyChannelBuilder.<clinit> when R8
strips a member its static init reaches by reflection. That sailed past
this catch, past the caller's bare try/finally
(RequestUserNameViewModel.checkNetworkHealth has no catch at all), out of
the coroutine, and killed the process — 11 times on one HONOR PTP-N49.

The probe feeds ONE advisory warning row on the username screen, and its
own KDoc says it must never gate the submit button. It should not have
been able to do that under any circumstances.

a772bdc keeps R8 from stripping the member. This is the other half:
whatever breaks underneath an advisory probe next, it degrades to UNKNOWN
instead of crashing. The keep rule buys immunity to a known trigger; this
buys survivability of an unknown one.

Two details that matter more than the widened catch:

  - CancellationException is rethrown. A blanket catch(Throwable) that
    swallowed it would break structured concurrency — the screen closing
    mid-probe would leave the caller's coroutine looking successful.

  - A non-Exception Throwable logs at ERROR with the full stack, not just
    toString(). Degrading silently would trade a loud crash for an
    invisible one, and it was precisely the stack in a QA report that made
    this diagnosable. The next stripped member should still be obvious in
    a log, just not fatal.

The KDoc's "the probe never throws" claim is now true, and says what it
excludes.

Tests: 4 added. The load-bearing one asserts probe() RETURNS at all —
under catch(Exception) it does not fail an assertion, it dies with the
Error, exactly as the app did. They force Constants.SUPPORTS_PLATFORM
true and restore it after: it is a mutable static set from a native-ABI
check, so it is false under a JVM test and every assertion would
otherwise pass vacuously on probe()'s first line.

Mutation-verified both ways: reverting to catch(Exception) fails 2,
dropping the cancellation rethrow fails 1. Full :wallet suite green.

Still open: the probe makes a legacy-dapi-client gRPC call at all
(PlatformHealthService.fetchDapiNodeCoreHeight). The value it wants,
ResponseMetadata.core_chain_locked_height, rides free on every platform
response but is not exported through the Kotlin SDK's JNI surface — so
removing netty from the live path is legacy-client retirement work, not a
bug fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…O-972)

Two QA reports on 12000007, two different failures, and neither log could
say what actually went wrong.

MO-973 (SM-A536B, shielded): the identity was created, then DPNS name
registration failed three times with

  IllegalStateException: username registration did not complete
    (retryable): pre-broadcast identity-key validation failure

That reason is a message-match on the FFI's "Invalid identity data", and
the label is the CONTACT-REQUEST reading of it — a missing
ECDSA_SECP256K1 encryption key. It was reported verbatim for a DPNS
registration, which needs no encryption key. The message is genuinely
overloaded: the invitation amount-cap rejection arrives with the same
prefix ("Invalid identity data: invitation amount ... exceeds the cap"),
which InviteCreationFailureTest has been pinning all along. So the label
named a cause nobody had established.

Worse, the engine's own message never reached the log at all:
RestoreIdentityWorker threw via `error(...)`, which builds an
IllegalStateException WITHOUT a cause, so `result.cause` was dropped on
the floor. BaseWorker does log.error(msg, e), so a cause WOULD have
printed as a "Caused by:" chain — there just wasn't one. The real reason
was unrecoverable from the report.

MO-972 (HONOR PTP-N49, transparent): reported as

  signing failure (pre-broadcast): Keystore auth window expired

  11:49:16  SendCoinsTaskRunner - authenticate with biometric
  11:49:17  transparent identity funding rejected pre-broadcast

One second. The window had not expired — the label asserted a timeout
nobody measured, and sent diagnosis the wrong way. The raw error is
"Generic Error: User not authenticated": the Keystore refused to treat a
fresh biometric as satisfying the identity key's auth gate. On the
Samsung the same flow gets PAST signing and fails later, differently, so
this is device-specific — the same OEM Keystore defect family as the
false-locked master alias, on the auth-gated identity alias that
dashpay/platform#4643 explicitly does not cover (#4060's DEVICE_BOUND
policy is the remedy). Nothing to fix in the wallet beyond not lying
about the cause.

So:
  - both reasons now carry the engine's message verbatim;
  - the auth reason states the refusal and offers expiry as one
    POSSIBILITY rather than a fact;
  - RestoreIdentityWorker and CreateIdentityService's invite path throw
    with `result.cause` attached, so the "Caused by:" chain reaches the
    log.

Checked the coupling before changing the strings:
classifyInviteCreationFailure matches over the reason AND the whole cause
chain, so REJECTED/UNREACHABLE verdicts are unchanged — the cause still
carries "Invalid identity data". Its two test literals are updated to
mirror the new production strings, with that coupling pinned so the next
reason-string edit fails loudly instead of silently reclassifying invite
failures.

Tests: 2 added. One asserts two different "Invalid identity data"
failures no longer read alike (missing-encryption-key vs amount-cap) —
the whole point of the change. One asserts the auth reason does not claim
an expiry as fact while staying recognisable. Both mutation-verified by
restoring the old labels. Full :wallet suite green.

This is diagnosis, not a fix: MO-973's actual cause is still unknown and
the next field report is what will name it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two CodeRabbit findings on #1555, both confirmed against the code.

CutoverCoordinator: the cutover boundary crossing is computable on
exactly one launch — `Configuration.lastVersionCode` is overwritten at
every startup — so when GATE 1's `set(CUTOVER_UPGRADE_BOUNDARY_CROSSED)`
threw, the crossing existed only as a local `val` and the explainer was
lost permanently. This launch's `armUpgradeNoticeIfUpgraded` reads the
STORE, not that value, so it saw `false` and returned; every later
launch computed `crossedNow == false` with nothing on disk to recover
from. The commit itself still happened, so the symptom is a user who
crossed the boundary silently never getting the one-time sync
explanation — MO-1022's symptom, reached by a different route.

The failure is now held in memory for the launch that observed it and
the persist is retried when the cutover commits. That covers the
transient case fully; if the store never accepts the key, arming still
proceeds from memory, which is all that can be salvaged — the
explainer's own flags need the same store, and armUpgradeNoticeOnce
already logs when they fail.

L1SyncStatusService: the IDLE/CONNECTING filterTarget took every floor
the height took EXCEPT the height's own raw value, so an engine snapshot
reporting currentHeight > targetHeight rendered inverted (2533400/2533349
— a row reading past 100%). Minor, and the non-idle branch passes such a
snapshot through unchanged anyway, but the comment directly above
claimed the pair "can never render as height > target" and that was not
true. Note CodeRabbit's stated mechanism is wrong: both fields are read
off the same `data.filters` snapshot in `toShadowSyncProgress`, so they
cannot drift apart — the gap needs an already-inverted snapshot, not a
partial update. Comment corrected to say what actually holds.

Three regression tests, each verified to fail against the unfixed code:
a dropped latch write that recovers, one that never persists, and an
inverted engine snapshot. Full wallet suite green (1964 tests).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
HashEngineering and others added 19 commits September 25, 2026 15:18
… cleanup stuck past 5 min in the background

BlockchainServiceImpl.onCreate rightly refuses to start while a previous
instance's cleanup is unfinished, but nothing ever gave up on a cleanup
that was never going to finish. On 2026-09-22 twelve alarm-driven starts
over four hours each logged "deadlock in onDestroy" and stopped themselves
while the process lived on with the engine off. Plan section 37.

The refused start now measures how long the active cleanup has been stuck
(cleanupStartedAtMs, set when cleanupDeferred is created). Past
CLEANUP_DEADLOCK_EXIT_MS (5 min) with the app in the background it ends
the process, so the next start — the alarm or the user — begins from a
clean one. Never while the app is visible: that start is the user's own,
and exiting would close the app on them; they get the refusal as before.
decideOnCleanupDeadlock is pure and pinned on both axes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…m issue 17 drafted

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… committed-range sweep, not a stuck block fetch

Andrei's 2026-09-22 engine log settles it: the final batch's 65 matched
blocks were all processed within six seconds, and the batch still did not
commit for seven minutes. The line in between is `Rescan committed filters
(934848-2539848) … (sweep #1)`: before committing the last batch, dash-spv
re-tests every filter since wallet birth against the scripts derived during
the scan, inline on the filter task, with no persisted progress. At P=19,
M=784931 a 13,024-script set false-matches 1.66% of all 1.6M mainnet
filters — ~26,000 block fetches for nothing — and a phone never finishes
before something stops the engine. Our own integration-branch commit
80e07b8f (durable pending-sweep set, #979) re-seeds the set at every start,
which is why the park recurs identically after every restart.

Already open upstream: rust-dashcore#1002 describes this wallet shape;
rust-dashcore#1016 (dash-spv owner, draft) drops the sweep. The issue draft
this section promised is retired in favour of a comment there.

Also: the 06:08 watchdog restart in the same log was a device-offline
stall (all peers ping-timed out, DNS down 21 min, new network at 06:21:49),
not an engine defect. The four app log lines and KDocs that stated the
pending-block story are reworded; no test asserted on them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
int22 is the int21 engine pin d525f431 plus two cherry-picks: rust-dashcore
#1015 (key-wallet: re-apply a spend whose coin was funded after it) and the
block-counter fix that stops re-applications inflating `processed`. It does
NOT carry #1016: the committed-range sweep and our durable pending-sweep set
are both still present, so the plan §34 final-batch park is unchanged on
this build.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…he per-launch re-walk is the DashPay backfill, not a park

Plan §38: emulator (debug 12000017) and Samsung (release 12000018) both
restored the job flower wallet on int22 to the correct seed with no
watchdog verdict, stall WARN or restart; the emulator's engine log shows no
committed-range sweep this time, with the ordering hypothesis stated as such.

Plan §34.1a corrected and §38.2: the 1,532,170 re-walk boundary seen on
every emulator session is coreHeightCreatedAt of the wallet's earliest
DashPay contact request; rs-platform-wallet's reconcile_dashpay_rescan
rewinds synced_height to it at every startup behind an in-memory guard
(dashpay/platform#4302, PR #4740). The mod-5,000 residue the earlier text
read as a batch-boundary park is the batch grid anchored at that start.

QA: A-01 gains the int22 restore evidence; A-03 records the per-launch
re-walk as an SDK defect; header and summary updated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ly at clean stop

The alarm had one call site: WalletApplication.scheduleStartBlockchainService
from onDestroy's cleanup coroutine, at the "detaching from wallet" step after
the onCreate latch and the check-mutex acquisition. A kill that lands before
that step leaves the wallet with no pending restart. Samsung SM-S901U,
2026-09-22: synced at 17:39, task swiped ~17:43 (deferred while the service
was started), idle stop 17:47:01.0, deferred "remove task" kill at
17:47:01.2 — 600 ms into cleanup. dumpsys alarm then listed no alarm for the
app's uid, and 70 minutes later there was still no process on an awake,
charging, battery-exempt device. lowmemorykiller on a cached process gives
no onDestroy at all and has the same effect. Plan section 36.6.

onCreate now arms the alarm after the foreground promotion. The scheduler
cancels and replaces its own PendingIntent, so the two arms are idempotent,
and a start delivered while the service is already running is an ordinary
onStartCommand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ole bring-up against a stop that timed out

Two review findings on the bounded stop.

Lock leak at the timeout boundary: `withTimeoutOrNull { lock.lock() }` can
deliver ownership to this coroutine while the timeout still wins the
completion, returning null with the lock held and nothing to release it —
on this singleton every later bring-up would then wait forever. stop() now
acquires with a tryLock poll (tryLockWithin): tryLock is non-suspending and
atomic, so ownership and the result cannot disagree, and the only suspension
is the poll delay, during which nothing is held.

Fence: the generation guard covered configure/bind only. A bring-up past it
could be parked in the native sync-loop start when stop() timed out and tore
the Kotlin side down; on return it published ready and started the
collectors and the pending-shield sweep over the teardown. And
hasShieldedSupport()/boundWalletIdOrNull() call ensureStarted(), so a stop in
that bootstrap interval was absorbed by a snapshot taken later. The
generation is now sampled first and re-checked after every native step; a
superseded bring-up stops the loop it started (or found running) and
declines.

Tests: a bring-up parked in the native loop start is superseded by stop(),
stops the loop it started and declines, and a fresh bring-up is whole again;
a stop during the bootstrap interval aborts the bring-up before it binds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…g-up that took the lock mid-poll is superseded too

A second bring-up queued on the lock wins the handoff over stop()'s
tryLock when the first releases, and snapshots the generation stop had
already produced at entry. If it then outlasts the timeout, the fallback
teardown left its snapshot equal to the current generation, so it started
the loop and published ready over the teardown. The fallback now bumps the
generation once more before tearing down, so every snapshot taken during
the poll window is stale by the time the teardown runs.

Test: stop_fallbackAlsoSupersedesASecondBringUpThatTookTheLockDuringItsPoll
— two bring-ups gated in their native binds around a polling stop; both
decline, no loop starts, a fresh bring-up afterwards is whole.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… sweep at 67,658 scripts; QA J-01

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ntactFloor; the backfill gate is the no-op binding

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Debug.getPss() walks /proc/self/smaps, and on the reference install's
2 GB replay process that held the main thread — inside the tick
broadcast receiver — for 5 s or more, four times in one evening (plan
§39.6). The sample now runs on the service scope, one in flight at a
time; a slow one skips ticks rather than queueing behind itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… sweep" reading; what int22 changes for Joel's wallet

The AAR's native-library hashes are the recipe's #1015 + #1016 build
(9d1804d6), not the #1015-only 08c0c04e that section 38 named; the 09-22
debug APK and the 09-23 release APK were both built against it. Section
38.1's provisioning-order explanation for "no sweep tonight" was wrong:
there is no sweep in int22. Section 39.4 separates the scan's own
matching (438–817 blocks per batch) from the sweep's additions (up to
800), and 39.7 / QA J-01 now say what 12000018 would and would not change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… in tenths

Over the whole chain the figure said nothing: a rewind to 2,167,092 under a
2,544,483 tip read 95.1 → 99.9% for three hours, and the 27,000-block
re-walk every launch pays sat at 99% from start to finish (Joel,
2026-09-25, plan §39.8).

ShadowSyncProgress carries the session's floor — the lowest header and
filter cursor reported since the engine started, a running minimum so the
DashPay backfill's rewind a few seconds in lowers it — and
shadowSyncPermille measures progress over [floor, target]. A fresh
restore (floor 0) reads as before. The persisted percentageSync stays a
whole number for its == 100 consumers; L1SyncUiStatus gains
percentageTenths and the home header shows "99.1%" / "99.9%" ("100%",
never "100.0%"; dashj's whole number pre-cutover). The 100 decision is
unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…bserved on screen; the balance question

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lished carries the recipe's §5 mid-sync-kill fund loss

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… DASH across seven change outputs

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t gap; the kill run was confounded by the once-only widening

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t once per heal version

The Rust side keeps AddressPool::gap_limit in memory only — no changeset,
no persistence — so every fresh process and every re-created SDK wallet
comes up at the defaults (30 for BIP44, 100 for CoinJoin). The widening
to 1000 ran once per heal version, so a process killed mid-scan resumed
at the defaults and never rediscovered the outputs paid to change
addresses the killed session had derived past used + 30: the kill-test
fund loss of 2026-09-23 and 2026-09-25 (plan §39.10), which only the
now-removed committed-range sweep (rust-dashcore#1016) used to mask.

The widening now runs on every bind, before the engine starts; the heal's
retroactive half (the rewind to birth and the coverage invalidation)
stays once per version.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…al to the clean control

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
HashEngineering and others added 3 commits September 26, 2026 08:20
…-cast vote as failure

Two defects in BroadcastUsernameVotesWorker, found voting a contested username
on emulator-5554 (testnet, 12000013). They interact: the second masked the
first.

1. The failure paths themselves threw. Both the all-failed and the
   partial-failure branches built KEY_LABELS as `arrayOfnames.map { labelMap[it] }`
   — a List. `androidx.work.Data` accepts String[] only, so every attempt to
   report a vote error died in Data.Builder.put with

     IllegalArgumentException: Key BroadcastUsernameVotesWorker.LABELS
       has invalid type class java.util.ArrayList

   and the user saw that instead of the real error (Firebase logged it as the
   reason on username_voting_details_btn_vote_fail; UsernameRequestsFragment
   logged "error processing vote information"). The success branch was already
   correct — it passes the String[] straight from inputData.

   `.toTypedArray()` alone would not have fixed it: `labelMap[it]` is a
   nullable lookup, and Data rejects Array<String?> for the same reason. A name
   with no display label now falls back to the NORMALIZED NAME rather than
   being filtered out — the observer reads KEY_LABELS and KEY_NORMALIZED_LABELS
   as parallel arrays, so dropping an element would desync them, and the
   normalized name is still truthful to show.

   Relatedly, a failed vote carries no Vote and so no poll name; the name array
   was substituting the literal string "null" for it, which is precisely the
   key that had no labelMap entry. Those are now dropped, falling back to the
   submitted labels when nothing is left.

2. "Vote is already present" was unwrapped as a hard failure. That error means
   the masternode has ALREADY voted this poll — the desired end state is true —
   yet it surfaced as "all votes failed". Failures are now classified and only
   terminal ones count against the broadcast; an already-cast vote reconciles
   as success and logs at info instead of error.

   The vote-budget error the worker's comments anticipate alongside it
   ("already voted 5 times ... they can only vote 5 times") stays terminal and
   is deliberately NOT collapsed into the same verdict. Since it also contains
   the words "already voted", the limit marker is matched FIRST; reversing the
   two branches would report a spent budget as a successful vote.

classifyVoteFailure / voteFailureText / labelsFor are pure top-level functions,
host-JVM testable without WorkManager or the SDK — the same shape as
isOwnContestedCandidate and contestedNameCandidates in RestoreIdentityWorker.
The two verbatim engine messages are pinned in VoteFailureClassificationTest so
an SDK reword fails loudly instead of silently reclassifying a duplicate vote
as fatal.

Not addressed here: an already-cast vote still writes no UsernameVote row,
because the failed Triple carries only the vote choice and not the poll name.
The success path's updateUsernameRequestWithVotes refresh reconciles the true
count from the network; recording it locally would mean widening
PlatformBroadcastService's return type.

Tests: 2009 pass, 0 failures (1996 baseline + 13 new). ktlintCheck clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MO-977 (Andrei, 12000014): the Username Voting list renders correctly in dark
mode until a contested name with multiple requests is expanded — each child row
then draws as a solid white bar with no visible text.

`username_request_view.xml` backs its row with
`@drawable/selectable_round_corners_border_light`, whose fill was a hardcoded
`@android:color/white`. The text on that row uses theme-aware styles
(`@style/Overline`), so in dark mode it turns white and disappears against the
white fill — white on white. The group row above it does not have the problem:
it uses `@drawable/rounded_background` with `?attr/backgroundColor`.

Fixed by making the fill follow the theme. `background_secondary` resolves to
`palette_white` (#FFFFFF) by day, which is byte-identical to the
`@android:color/white` it replaces, so light mode is unchanged; at night it
resolves to `palette_black_800`, matching the group card the rows sit inside —
the same relationship light mode already had, with the 1dp stroke providing the
separation in both themes.

The stroke and ripple are left alone: `button_ripple_light` is a 25%-alpha gray
(#40B0B6BC) that composites correctly over either background.

Scope: this drawable has exactly one usage, the row being fixed, so there is no
collateral. Its "_light" name is now a misnomer, kept to hold the diff to the
defect; a comment in the file records why the fill must stay theme-aware.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@HashEngineering
HashEngineering force-pushed the fix/username-voting-already-cast branch from 914515a to 69f90a5 Compare September 26, 2026 22:40
@HashEngineering

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review — Final validation — Phase 1 + Phase 2

Verified the complete diff at 68c2a65 and traced voting outcomes through the service, worker, operation, and UI. Both prior findings are fixed, but batches containing fresh successes and terminal failures now reach callers as unqualified success without exposing the failures. The theme-aware drawable change is consistent with the day/night resources; whitespace checks passed, and tests were inspected but not rerun.

🔴 1 blocking

Review provenance

Source: reviewer 1: muse-spark-1.3-contributor (agent: phase1-reviewer, role: general); reviewer 2: gpt-6-astra (agent: phase2-reviewer, role: general); reviewer 3: gpt-6-astra (agent: phase2-reviewer, role: general); final verifier: gpt-6-astra (agent: astra-verifier, role: final-verifier)

  • Triage: normal by gpt-6-astra (effort low) — The diff changes nontrivial vote-error classification, terminal-failure propagation, and parallel output-label handling plus dark-mode rendering and tests, but does not alter consensus, funds movement, cryptography, key handling, network deserialization, or storage migrations.
  • Phase 1 reviewers: muse-spark-1.3-contributor — general (completed, effort xhigh); agent phase1-reviewer
  • Phase 1 model: muse-spark-1.3-contributor — not quota-gated; passed over gemini-3.8-flash-high (antigravity below 15% reserve: weekly 13% left, 5h 100% left), glm-5.3-flash (not used above high effort; tier asks max)
  • Fresh final gate: an independent Phase-2 review ran after iterative findings were reconciled
  • Fresh verifier: gpt-6-astra — final-verifier; agent astra-verifier
  • Phase 2 reviewers: gpt-6-astra — general (completed, effort high); agent phase2-reviewer, gpt-6-astra — general (completed, effort high); agent phase2-reviewer
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `wallet/src/de/schildbach/wallet/ui/dashpay/work/BroadcastUsernameVotesWorker.kt`:
- [BLOCKING] wallet/src/de/schildbach/wallet/ui/dashpay/work/BroadcastUsernameVotesWorker.kt:210-215: Expose terminal errors when another vote succeeds
  When at least one fresh vote succeeds and another has a terminal failure, this branch returns success with every submitted name and no error information. For example, a quick-vote batch where Alice succeeds and Bob reaches the vote limit becomes Status.SUCCESS in BroadcastUsernameVotesOperation.convertState; UsernameRequestsFragment shows the success indicator and removes its observer, while Bob's error remains only in logs. This is exposed by this PR's serialization fix: the old List-valued KEY_LABELS threw before this branch could return success. Preserve the votes already recorded, but propagate the terminal error either through Result.failure with KEY_ERROR_MESSAGE or through an explicit partial-result contract handled by the operation and UI. Extend the mixed-success regression test to assert that observers receive the failure information, rather than only asserting Result.Success.

Comment on lines 210 to 215
Result.success(
workDataOf(
KEY_NORMALIZED_LABELS to arrayOfnames,
KEY_LABELS to arrayOfnames.map { labelMap[it] },
KEY_LABELS to labelsFor(arrayOfnames, labelMap),
KEY_VOTE_CHOICES to voteChoices,
KEY_QUICK_VOTING to isQuickVoting

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Expose terminal errors when another vote succeeds

When at least one fresh vote succeeds and another has a terminal failure, this branch returns success with every submitted name and no error information. For example, a quick-vote batch where Alice succeeds and Bob reaches the vote limit becomes Status.SUCCESS in BroadcastUsernameVotesOperation.convertState; UsernameRequestsFragment shows the success indicator and removes its observer, while Bob's error remains only in logs. This is exposed by this PR's serialization fix: the old List-valued KEY_LABELS threw before this branch could return success. Preserve the votes already recorded, but propagate the terminal error either through Result.failure with KEY_ERROR_MESSAGE or through an explicit partial-result contract handled by the operation and UI. Extend the mixed-success regression test to assert that observers receive the failure information, rather than only asserting Result.Success.

source: gpt-6-astra (phase2-reviewer: general)

@HashEngineering
HashEngineering force-pushed the fix/sync-process-stalls branch 2 times, most recently from bc798f6 to c7fe51b Compare September 28, 2026 02:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants