From 74d6958b3df258bf703f51515a9a97c383a90fbe Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 06:56:04 +0000 Subject: [PATCH 01/30] 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 --- docs/user-stories.md | 133 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/docs/user-stories.md b/docs/user-stories.md index 598a01679..af4904cfb 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -262,6 +262,31 @@ As a user, I want the app to tell me when a wallet's totals don't add up after s - The banner is not repeated on every later sync while the same difference persists, and it clears on its own once the totals agree again. - The check runs for all loaded wallets, not just the one currently on screen. +### WAL-028: View and copy my shielded receive address [Gap] +**Persona:** Jordan + +As a developer, I want to view and copy my own shielded receive address (and generate additional diversified addresses) so that I can give it to another party to receive a private transfer. + +- The Shielded tab shows the wallet's default shielded address once the wallet is unlocked and synced. +- A copy-to-clipboard control is available, and additional diversified addresses can be generated on demand. +- Currently a gap: the Shielded tab renders only a placeholder ("Shielded address available after wallet unlock and sync"); the underlying `WalletBackend::shielded_default_address` read is async-only and not surfaced synchronously to the UI, so no address, copy control, or diversified-address generator is shown. + +### WAL-029: Inspect shielded note details [Gap] +**Persona:** Jordan + +As a developer, I want to see the individual notes in my shielded pool — their value, block height, and spent/unspent status — so that I can verify and diagnose my shielded balance. + +- The Shielded tab lists each note with value, block height, and spent/unspent state, plus a synced-index and note-count summary. +- Currently a gap: the Shielded Notes section renders only a placeholder ("Note history is managed by the upstream platform-wallet coordinator and will be surfaced here in a future update") — no per-note table, status, or count is shown. + +### WAL-030: Refresh a single-key wallet's balance and UTXOs [Gap] +**Persona:** Priya, Jordan + +As a user with an imported single-key wallet, I want to refresh its balance and UTXO list so that I can see funds that arrived at the imported address. + +- A refresh action updates the single-key wallet's balance and UTXO set from the chain. +- Currently a gap (Decision #7): `RefreshSingleKeyWalletInfo` returns a "not supported in this version" result; single-key balance/UTXO refresh is re-enabled when single-key moves onto the upstream wallet runtime that provides UTXO discovery. Key data and receive still work. + --- ## Send and Receive (SND) @@ -387,6 +412,24 @@ As a user, I want a "Max" button on a Core-to-Core send that fills in the larges - The fee reserved is shown next to the amount. - When the balance is too low to cover the fee, "Max" produces no amount and a calm message explains why — never an error path. +### SND-015: Unshield credits to a Platform address [Implemented] +**Persona:** Jordan + +As a developer, I want to move credits out of the shielded pool to one of my Platform addresses so that I can use them for ordinary Platform operations. + +- Select Shielded Pool as source and enter a Platform address as destination. +- Reachable from the Shielded tab's "Unshield" button, which opens the unified Send screen preset for this flow. +- The shielded balance decreases and the Platform address balance increases after the operation completes. + +### SND-016: Send privately within the shielded pool [Implemented] +**Persona:** Jordan + +As a developer, I want to transfer credits privately from my shielded pool to another shielded address so that the transfer amount and parties are not exposed on Platform. + +- Select Shielded Pool as source and enter a shielded address as destination. +- Reachable from the Shielded tab's "Send (Private)" button, which opens the unified Send screen preset for this flow. +- Spending is paused until the shielded balance is verified, and the button is disabled with a clear reason while verification is in progress. + --- ## Asset Locks (ALK) @@ -620,6 +663,22 @@ As a masternode operator, I want to apply voting choices across multiple contest - "Set all" option for batch vote assignment. +### DPN-008: Set an alias for an owned username [Implemented] +**Persona:** Alex, Priya + +As a user, I want to assign a friendly alias to an identity behind a username I own so that I can recognise it more easily in lists. + +- Alias set from the "My usernames" table. +- Alias persists and is applied to the underlying identity. + +### DPN-009: Scheduled votes preserved across an app upgrade [Gap] +**Persona:** Priya + +As a masternode operator, I want my previously scheduled DPNS votes to survive an app upgrade so that I do not miss a contest's vote window after updating. + +- Scheduled votes stored before the upgrade remain visible and executable afterward. +- Note (PROJ-034): not migrated — legacy scheduled votes start empty after the platform-wallet upgrade, risking missed vote windows for masternode voters. + --- ## DashPay (DPY) @@ -1138,6 +1197,56 @@ As an everyday user, I want to install and use Dash Evo Tool without having to r - The user sees sync progress and status clearly; the default everyday-user UI avoids mentions of SPV, RPC, or nodes. - Technical/protocol terminology may appear in Expert mode or advanced settings, where Dash Core RPC remains available as an opt-in for users who do run a local node. +### NET-016: Refresh Platform (DAPI) node list [Implemented] +**Persona:** Priya, Jordan + +As a user, I want to fetch a fresh list of Platform (DAPI) node addresses from Dash Core Group's directory so that I can recover connectivity when my configured nodes are stale or unreachable. + +- "Refresh DAPI endpoints" action available on Mainnet and Testnet. +- Confirmation prompt before replacing an existing configured address set. +- New addresses are persisted to config and the SDK reinitialized without an app restart. + +### NET-017: View live connection status (indicator and Platform endpoints) [Implemented] +**Persona:** Alex, Priya, Jordan + +As a user, I want a clear connection indicator and status rows so that I know at a glance whether the app is connected, syncing, or errored. + +- Top-panel five-state indicator (synced, connecting, syncing, error, disconnected) with a hover tooltip. +- Settings screen shows Platform (DAPI) availability with jargon-free labels; raw sync errors are offered only on hover. (SPV sync detail is covered by WAL-013.) + +### NET-018: Auto-start SPV sync on startup [Implemented] +**Persona:** Priya, Jordan + +As an expert user, I want the app to automatically begin SPV sync when it opens so that my wallet is ready without pressing Connect each launch. + +- Expert-mode toggle "Auto-start SPV on startup", persisted across launches. +- When enabled, sync begins automatically on app launch. + +### NET-019: Clear all local data for a network [Implemented] +**Persona:** Jordan, Priya + +As a user, I want to permanently delete all local data for the current network — wallets, tokens, contacts, and cached identity data — so that I can reset the app to a clean state. + +- Danger-mode confirmation dialog before deletion; the action cannot be undone. +- Available for the currently selected network, including Mainnet. +- Distinct from NET-011 (Wipe Platform data), which clears only cached Platform state on Devnet/Testnet. + +### NET-020: Clear cached SPV data to force a resync [Implemented] +**Persona:** Priya, Jordan + +As an expert user, I want to clear the cached SPV headers and filter data for a network so that the next connection performs a full resync when local chain state is corrupt or stale. + +- Expert-mode "Clear SPV Data" action with confirmation; disabled while SPV is active. +- The next connection triggers a full resync. + +### NET-021: App settings preserved across an app upgrade [Gap] +**Persona:** Alex, Priya, Jordan + +As a user, I want my saved settings — selected network, theme, onboarding state, and paths — to survive an app upgrade so that I do not silently relaunch into the wrong network or a reset configuration. + +- Settings stored before the upgrade remain applied afterward. +- Note (PROJ-034): not migrated — existing users get default AppSettings on first launch after the platform-wallet upgrade (network resets to Mainnet; theme, onboarding, and paths reset); no importer exists (`db.get_settings` has zero callers). Scheduled-vote loss is tracked separately in DPN-009. + --- ## Programmatic Access (MCP) @@ -1217,6 +1326,14 @@ As any user, I want the same wallet/identity switcher on every page, so that I c - On a page that does not yet consume a given pill, that pill renders dimmed with no caret; a hover tooltip explains how to change the selection elsewhere. - A page with no identity/object context (e.g. a Wallet page) shows only the wallet pill. +### UX-004: One-time post-migration disclosure notice [Gap] +**Persona:** Alex, Priya, Jordan + +As an existing user upgrading into the platform-wallet version, I want a one-time in-app notice explaining what changed — notably that direct funding from a scanned external payment (QR) was removed — so that I understand why a workflow I relied on is gone. + +- A one-time notice appears on first launch after the migration, disclosing the removed QR-direct-fund path (referenced by NET-008 and IDN-014). +- Note (DOC-003): not shipped — only a generic "Storage update complete — your wallet is ready." banner appears (`src/app/reconcilers.rs:472`); the promised disclosure notice was deferred and never landed. + ## Identities Hub (IDH) ### IDH-001: First-time identity setup [Implemented] @@ -1357,3 +1474,19 @@ As a masternode operator, I want the Masternodes tab to reset to a clean state w - Switching networks while the Masternodes tab is on the List view (including with a filled-but-unsubmitted Load form) returns to the empty List view for the newly active network — no leftover ProTxHash/alias/key input from the previous network's form. - Error and status banners raised on the previous network (e.g. a failed load, a disconnect notice) are cleared by the switch rather than lingering over the new network's view. - Verified by manual walkthrough switching Testnet → Mainnet → Testnet from a dirty Load form; each switch landed cleanly on the empty List with no stale data or banners. + +### MN-011: Refresh masternode and voting state [Implemented] +**Persona:** Priya + +As a masternode operator, I want a Refresh control on the Masternodes tab, so that I can pull the latest identity and DPNS-contest state without leaving the page. + +- The card-list toolbar and a node's detail view each expose a Refresh action that re-reads the local cache immediately and dispatches a network re-fetch — one identity refresh per loaded node (or the single open node on the detail view) plus a DPNS-contest re-query so vote counts update too. +- Refresh is a no-op when no node is loaded, and the detail-view re-query is skipped for a node that has no voter identity. + +### MN-012: Switch wallet/identity from the Masternodes header [Gap] +**Persona:** Priya + +As a masternode operator, I want the same page-scoped switcher on the Masternodes header as on other tabs, so that I can see and change the active wallet and the node in view without leaving the page. + +- The Masternodes header renders the page-aware breadcrumb with an interactive wallet pill (the funding source for Top Up), which two-way binds with the page's wallet context. +- **Gap:** the page-scoped masternode object pill (an interactive `Ⓜ node ▾` dropdown, two-way bound with the card grid and detail view) required by the global-nav design is not built — only the wallet pill is present. Node selection currently works via card-click → detail and the "‹ All masternodes" back link. This is a deferred slice of the cross-cutting global-navigation app-shell work, tracked separately from the Masternodes page. From 27467fe8103cb4bc10997b75d36a3652754afca5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:07:21 +0000 Subject: [PATCH 02/30] refactor(network-chooser): drop dead dashmate_password_input field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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 --- src/ui/network_chooser_screen.rs | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index eb3a5ff4d..c01d45e5b 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -10,7 +10,6 @@ use crate::model::wallet::DerivationPathHelpers; use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::left_panel::add_left_panel; -use crate::ui::components::password_input::PasswordInput; use crate::ui::components::styled::{ ConfirmationDialog, ConfirmationStatus, StyledCard, StyledCheckbox, island_central_panel, }; @@ -63,7 +62,6 @@ pub struct NetworkChooserScreen { pub network_contexts: BTreeMap>, /// Shared data directory (same for all networks). data_dir: PathBuf, - dashmate_password_input: PasswordInput, pub current_network: Network, pub recheck_time: Option, developer_mode: bool, @@ -96,17 +94,6 @@ impl NetworkChooserScreen { let data_dir = any_context.data_dir.clone(); - let mut dashmate_password_input = PasswordInput::new() - .with_hint_text("Core RPC password") - .with_char_limit(40) - .with_desired_width(280.0); - if let Ok(config) = Config::load_from(&data_dir) - && let Some(network_config) = config.config_for_network(current_network) - { - dashmate_password_input - .set_text(network_config.core_rpc_password.clone().unwrap_or_default()); - } - let current_context = contexts.get(¤t_network).unwrap_or(any_context); let developer_mode = current_context.is_developer_mode(); @@ -117,7 +104,6 @@ impl NetworkChooserScreen { Self { network_contexts: contexts.clone(), data_dir, - dashmate_password_input, current_network, recheck_time: None, developer_mode, @@ -197,7 +183,6 @@ impl NetworkChooserScreen { let response = ui.add_enabled_ui(!is_spv_connected, |ui| { network_combo.show_ui(ui, |ui| { - let prev_network = self.current_network; if ui .selectable_value( &mut self.current_network, @@ -241,17 +226,6 @@ impl NetworkChooserScreen { { app_action = AppAction::SwitchNetwork(Network::Regtest); } - if self.current_network != prev_network { - let password = Config::load_from(&self.data_dir) - .ok() - .and_then(|c| { - c.config_for_network(self.current_network) - .as_ref() - .and_then(|nc| nc.core_rpc_password.clone()) - }) - .unwrap_or_default(); - self.dashmate_password_input.set_text(password); - } }); }); From 4d26fe39f716b73d132aab569806ea9b4e703d2d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:11:16 +0000 Subject: [PATCH 03/30] 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. --- CHANGELOG.md | 13 ++++++++++ .../backendtask-contract.md | 4 ++-- .../phasing.md | 5 ++-- .../2026-06-01-pr860-gap-audit/gaps.md | 3 ++- .../not-planned-signoff.md | 24 +++++++++++++++++++ src/model/settings.rs | 2 ++ 6 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 docs/ai-design/2026-07-13-v1.0-parity-closure/not-planned-signoff.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc9de5ba..4ed9519ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -115,9 +115,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). reproduced. Re-establishing the contact from both sides restores full functionality. +- **Shielded notes — no per-note detail view**: the Shielded tab no longer lists + individual notes (value, block height, spent/unspent status) or a synced-index + and note-count summary. Your shielded balance total is still accurate; only the + note-level breakdown is unavailable in this release. + ### Removed - Proof log screen (internal developer tool, not part of the public feature set). + Proof-log records now go only to the `tracing` log target — both the persisted + history and the in-app viewer are gone; there is no replacement UI to inspect + past entries. - QR-code wallet import flow for identity funding and top-up screens. - The "fund identity directly from a transaction output" option on the identity registration and top-up screens (replaced by the single asset-lock funding flow @@ -135,6 +143,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - The unreachable Dash-Qt launcher and its settings — the executable path, the overwrite-config option, and the close-on-exit option. There was no way to launch Dash-Qt from the app, so the controls had no effect and have been removed. +- The Masternode List Diff inspector (Tools), which showed additions, removals, and + changes to the masternode list between blocks. No replacement is planned. +- The "Total Received (DASH)" column on the wallet address table. There is no + upstream source for cumulative historical receipts post-migration, so the column + cannot be populated. ### Fixed diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md b/docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md index 54f84cfe5..afb80dac8 100644 --- a/docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md @@ -40,7 +40,7 @@ The `BackendTask` enum (`src/backend_task/mod.rs:92-100`) and the action/channel | `CoreTask::CreateAssetLock` | **modified** | Build via upstream; broadcast via `SpvRuntime`. Result unchanged. | | `CoreTask::ListCoreWallets` | **hard-removed** | Named Core wallets are RPC-only; meaningless without RPC mode. Hard-removed immediately; UI entry point (Core-wallet picker) deleted same release (Decision #8). | | `CoreTask::RecoverAssetLocks` | **hard-removed** | Upstream `AssetLockManager` tracks continuously; explicit recovery is obsolete. Hard-removed immediately; UI entry point deleted same release (Decision #8 — no one-release grace). | -| `IdentityTask::RegisterIdentity` / `IdentityTask::TopUpIdentity` | **modified — `FundWithUtxo` variants removed** | DAPI/SDK state-transition flows. `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` are removed in P4a.5 (no upstream funding-outpoint API exists at #3625 head; cannot be preserved). Accepted user-facing behavior change: identity registration and top-up are funded only from wallet-managed balance via `WalletBackend::create_asset_lock_proof`. External scanned-outpoint direct funding is removed and disclosed via the one-time post-migration notice. All other identity task variants (transfer, withdraw, add_key, load, discover, refresh) are internally rewired with signatures stable and UI unaffected. | +| `IdentityTask::RegisterIdentity` / `IdentityTask::TopUpIdentity` | **modified — `FundWithUtxo` variants removed** | DAPI/SDK state-transition flows. `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` are removed in P4a.5 (no upstream funding-outpoint API exists at #3625 head; cannot be preserved). Accepted user-facing behavior change: identity registration and top-up are funded only from wallet-managed balance via `WalletBackend::create_asset_lock_proof`. External scanned-outpoint direct funding is removed and disclosed via CHANGELOG. All other identity task variants (transfer, withdraw, add_key, load, discover, refresh) are internally rewired with signatures stable and UI unaffected. | | `IdentityTask::*` (transfer/withdraw/add_key/load/discover/refresh) | **mostly kept, internally rewired** | DAPI/SDK state-transition flows — zero `CoreBackendMode` branches. Identity state read via upstream `IdentityManager`/`IdentityWallet`; `QualifiedIdentity` blob retained. Signatures stable; UI unaffected. `discover_identities` keeps DET Devnet path (see [open-questions.md #4](open-questions.md)). | | `IdentityTask::RegisterDpnsName`, DPNS load/refresh | **kept** | No upstream DPNS register flow (`DpnsNameInfo` is read-only). DET-owned permanently. | | `DashPayTask::*` (contact request/accept, profile, avatar, auto-accept, incoming payments) | **modified, hybrid** | Contact-request/established-contact/profile state + crypto via upstream; DET keeps orchestration, avatar I/O, auto-accept proof, incoming-payment detection (Decision #5 hybrid split). DIP-14/15 derivation delegated upstream (`dip14_derivation.rs`/`hd_derivation.rs` deleted, subject to migration execution). Contacts are re-established on upstream derivation unconditionally — no quarantine error path (Decision #6, 2026-05-18 re-resolution; see [data-model-and-migration.md](data-model-and-migration.md) — "Accepted fund-accessibility trade-off"). `TaskError::DashPayContactDerivationIrreconcilable` is unused by the migration path — candidate for P4 removal if no other caller. Result variants stable; UI unchanged. | @@ -62,4 +62,4 @@ Result variants and the action/channel contract are preserved — UI screens are 2. Single-key screens show a not-supported banner (read-only view of preserved data). 3. SPV sync-progress UI fed from upstream `sync_progress()` via thin `ConnectionStatus` adapter — visual parity, different source. 4. `RefreshWalletInfo` returns near-instantly (upstream is already syncing). -5. **`FundWithUtxo` removed (P4a.5):** The option to fund an identity directly from a scanned external outpoint (QR-direct-fund UI) is no longer available. Identity registration and top-up accept only wallet-managed balance as the funding source. This change is disclosed via the one-time post-migration informational notice shown to all migrated users. +5. **`FundWithUtxo` removed (P4a.5):** The option to fund an identity directly from a scanned external outpoint (QR-direct-fund UI) is no longer available. Identity registration and top-up accept only wallet-managed balance as the funding source. This change is disclosed via CHANGELOG. diff --git a/docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md b/docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md index 0d827d66a..10a8c325d 100644 --- a/docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md +++ b/docs/ai-design/2026-05-18-platform-wallet-migration/phasing.md @@ -104,7 +104,7 @@ No funding-outpoint API exists in `platform-wallet` at PR #3625 head. The `FundW - `top_up_asset_lock_transaction_for_utxo` - `asset_lock_transaction_for_utxo_from_private_key` -This is a user-facing capability removal. It is disclosed via the one-time post-migration notice (see [data-model-and-migration.md § Mandatory one-time informational notice](data-model-and-migration.md#accepted-fund-accessibility-trade-off-user-decision-2026-05-18)). +This is a user-facing capability removal. It is disclosed via CHANGELOG. **Path 3 — `received_transaction_finality` slim (asset-lock-finality-only).** Slim `context/transaction_processing.rs::received_transaction_finality` to handle only asset-lock finality. Delete the `Wallet.utxos` / `address_balances` / legacy-`utxos`-table write branches. RETAIN the asset-lock detection and registration branch: `store_asset_lock_transaction` + the finality-wait channel that `broadcast_and_commit_asset_lock` and `wait_for_asset_lock_proof` depend on. ZMQ call sites at `app.rs:1267,1285` stay — ZMQ is still required for asset-lock detection. @@ -191,8 +191,7 @@ The Smythe security audit is a **release-blocking gate** at P5. No push to #860 |---|---|---| | **I1** | Authoritative selection at construction | No code path selects spendable inputs from `WalletSnapshot` or any `Wallet.utxos` snapshot. All coin-selection goes through `WalletBackend::create_asset_lock_proof` or `WalletBackend::send_payment` (upstream live UTXO set). | | **I2** | No DET-side parallel spend engine | The functions `select_unspent_utxos_for`, `select_utxos_with_fee_retry`, `generic_asset_lock_transaction`, `registration_asset_lock_transaction_for_utxo`, `top_up_asset_lock_transaction_for_utxo`, `asset_lock_transaction_for_utxo_from_private_key`, `remove_selected_utxos`, `build_multi_recipient_payment_transaction` are deleted, not orphaned. No dead caller, no commented-out call, no unreachable arm. | - -| **I3** | `FundWithUtxo` removal disclosed | The one-time post-migration notice text ships in the release build. `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` variants are gone. No dead erroring arm remains in any match on either enum. | +| **I3** | `FundWithUtxo` removal disclosed | The removal is disclosed via CHANGELOG (no in-app one-time notice ships). `RegisterIdentityFundingMethod::FundWithUtxo` and `TopUpIdentityFundingMethod::FundWithUtxo` variants are gone. No dead erroring arm remains in any match on either enum. | | **I4** | Crash-retry no-double-broadcast | Asset-lock transactions are stored (durable) before broadcast. Upstream deduplication prevents double-broadcast on retry. Store-before-broadcast ordering is verified by test. | | **I5** | Path 3 deletion leaves asset-lock detection intact | `received_transaction_finality` no longer writes to `Wallet.utxos` / `address_balances` / legacy `utxos` table. The asset-lock detection branch (`store_asset_lock_transaction` + finality-wait channel) is fully functional. `broadcast_and_commit_asset_lock` and `wait_for_asset_lock_proof` succeed in test without any `Wallet` mutation. | | **I6** | No frame-thread blocking | No code path added in P4a, P4a.5, or P4b causes the egui frame thread to await or block on a wallet operation. All upstream calls are dispatched through `BackendTask` / `WalletBackend` async methods. | diff --git a/docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md b/docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md index 598940870..68508f5d5 100644 --- a/docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md +++ b/docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md @@ -732,6 +732,7 @@ absent in the working tree; none is a new gap. | SPV peer-source expert setting ("Use local Dash Core node" for peer discovery) | OLD `network_chooser_screen.rs:1222-1269`; `db.get_use_local_spv_node()` | `removal-inventory.md:55` | Upstream owns peer discovery; devnet/regtest host config via `.env` unchanged. Record-only (was GAPCMP-D-06). | | Proof Log screen + persistence | OLD `src/ui/tools/proof_log_screen.rs` (426 lines), `src/database/proof_log.rs`, `insert_proof_log_item` writers | `CHANGELOG.md:50`; commit `7778eb64` | Replaced by `tracing` target `"proof_log"` — history no longer survives restart. Stale doc refs → DOC-002. | | "Total Received (DASH)" address-table column | OLD `address_table.rs` `TotalReceived` sortable column | In-code comment ("no upstream source post-migration") | CHANGELOG line missing → DOC-001. | +| Masternode List Diff inspector (`RootScreenType` slot 23) | OLD `src/ui/tools/masternode_list_diff_screen.rs` (~4460 lines) | `CHANGELOG.md` "Removed"; gravestone comment at `src/model/settings.rs` slot 23; `docs/user-stories.md` DEV-006 `[Removed]` | No replacement planned. Was undisclosed for six weeks after first flagged 2026-06-29; closed 2026-07-13. | --- @@ -794,7 +795,7 @@ counted as new open gaps): TC-SK-010, TC-A11Y-008, TC-PERF-003. | PROJ-021 | CHANGELOG omits single-key capability limits and DIP-14 trade-off | `CHANGELOG.md:31-46` | LOW | **RESOLVED (`f39b085d`)** | `### Known Limitations` section now states single-key send/refresh is unsupported this release and documents the DIP-14 non-mainnet/non-account-0 contact-fund re-establishment trade-off. | | DOC-001 | CHANGELOG disclosure sweep — Removed/Known-Limitations/Fixed sections incomplete | `CHANGELOG.md:33-56` | MEDIUM | **RESOLVED 2026-06-11** (`1871c59f` + `23b81718`) — CHANGELOG Removed and Known Limitations sections updated; ZMQ and Dash-Qt launcher removals recorded in `23b81718`. | | DOC-002 | Proof-log removal untracked in audit + stale user-story/persona refs | `docs/user-stories.md:878` (DEV-002 still `[Implemented]`); `docs/personas/platform-developer.md:27,75` | LOW | **RESOLVED 2026-06-11** (`1871c59f`) — DEV-002 user story tag flipped and persona references corrected. (was GAPCMP-B-3 + D-01) | -| DOC-003 | Promised one-time post-migration notice (invariant I3) never shipped | `docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md:43,65`; `phasing.md:194` (I3); `docs/user-stories.md` IDN-014 rationale | LOW | OPEN (deferred-with-TODO, `727e8d6a`) | Three doc sites commit to an in-app one-time notice disclosing the QR-direct-fund removal; the only post-migration banner is the generic "Storage update complete — your wallet is ready." (`src/app.rs:1130-1137`). Either ship the notice text or amend I3 + the three doc sites to say "disclosed via CHANGELOG". (was GAPCMP-B-4) | +| DOC-003 | Promised one-time post-migration notice (invariant I3) never shipped | `docs/ai-design/2026-05-18-platform-wallet-migration/backendtask-contract.md:43,65`; `phasing.md:194` (I3); `docs/user-stories.md` IDN-014 rationale | LOW | **RESOLVED 2026-07-13** — the notice was never shipped and none is planned; all three sites (`backendtask-contract.md:43,65`, `phasing.md` I3) now say the removal is disclosed via CHANGELOG instead of promising an in-app notice. (was GAPCMP-B-4) | **PROJ-018 (PARTIAL).** Verified at `docs/ai-design/2026-06-03-pr860-doc-followups/external-docs-draft.md`: a full external-docs draft now exists, targeting `dashpay/docs` → diff --git a/docs/ai-design/2026-07-13-v1.0-parity-closure/not-planned-signoff.md b/docs/ai-design/2026-07-13-v1.0-parity-closure/not-planned-signoff.md new file mode 100644 index 000000000..860a7970b --- /dev/null +++ b/docs/ai-design/2026-07-13-v1.0-parity-closure/not-planned-signoff.md @@ -0,0 +1,24 @@ +# v1.0 Parity Audit — "Accepted as Gone, Not Planned" Sign-Off + +Closes the batch of v0.10-dev-vs-PR860 UI parity findings verified as genuine, +intentional removals with no restoration planned, and already disclosed to +users before this record was written. Each item below was independently +re-verified against the current working tree (no UI control, task, or field +remains reachable) and cross-checked against an existing disclosure site. + +| Finding | What's gone | Verified gone | Disclosure | +|---|---|---|---| +| Search for Unused asset locks | `CoreTask::RecoverAssetLocks` and its "Search for Unused" button | No references in `src/`; replaced by continuous `AssetLockManager` tracking (`WalletTask::ListTrackedAssetLocks`) | `docs/ai-design/2026-06-01-pr860-gap-audit/gaps.md` disclosed-removals table (`CoreTask::RecoverAssetLocks` row) | +| Shielded manual Sync / dev Resync buttons | Manual sync-notes and nullifier-recheck controls on the Shielded tab | No sync/resync buttons in `src/ui/wallets/shielded_tab.rs`; the shielded op enum no longer carries `SyncNotes`/`CheckNullifiers`/`WarmUpProvingKey` — sync is upstream-owned and automatic | Commit `479c8c18` ("delete DET shielded subsystem, route via upstream") is the removal record; no separate CHANGELOG/gaps.md line names the buttons specifically — flagged for awareness, not blocking, since the net effect (automatic sync) is a strict improvement | +| Connection Type (RPC vs SPV) selector | Network settings toggle between RPC and SPV backend modes | No "Connection Type" selector in `src/ui/`; `platform-wallet` is SPV-only by design | `gaps.md` disclosed-removals table ("RPC Core-backend mode" row) | +| RPC Core / ZMQ status rows | Status rows showing RPC and ZMQ connection health | No references in `src/ui/`; removed together with RPC mode | Same `gaps.md` row, plus CHANGELOG "Removed" (ZMQ listener/"Disable ZMQ" line) | +| Dash Core executable path config | "Dash Core Executable Path" file picker in Network Settings | No `dash_qt_path`/executable-path UI in `src/ui/`; the field survives only in `AppSettings`/wire format for on-disk layout compatibility, never rendered | CHANGELOG "Removed" — "the unreachable Dash-Qt launcher and its settings — the executable path, the overwrite-config option, and the close-on-exit option" | +| Overwrite dash.conf checkbox | Network Settings checkbox to let DET rewrite `dash.conf` | No UI references; `overwrite_dash_conf` unused outside settings persistence | Same CHANGELOG line as above | +| Close Dash-Qt when DET exits checkbox | Network Settings checkbox for close-on-exit behavior | No UI references; `close_dash_qt_on_exit` unused outside settings persistence | Same CHANGELOG line as above | +| Disable ZMQ toggle | Network Settings checkbox to opt out of the Core ZMQ listener | No UI references; was already a placebo (listener never spawned) before removal | CHANGELOG "Removed" — ZMQ listener/"Disable ZMQ" line; `gaps.md`'s ZMQ-subsystem finding documents the placebo status pre-removal | +| SPV Peer Source expert setting | "Use local Dash Core node" peer-discovery toggle | No `use_local_spv_node`/"Peer Source" references in `src/`; upstream `platform-wallet` owns peer discovery | `gaps.md` disclosed-removals table ("SPV peer-source expert setting" row) | +| Dash-Qt launch button (status card) | Connection-indicator click launched Dash-Qt | No `StartDashQT` UI callers in `src/`; task struct itself removed | CHANGELOG "Removed" Dash-Qt launcher line; `gaps.md`'s Dash-Qt-launcher finding (resolved by commit `255aa018`) | + +All ten findings are closed. No CHANGELOG or `gaps.md` edits accompany this +record — those disclosures already existed; this document is the formal +sign-off closing the outstanding audit findings against them. diff --git a/src/model/settings.rs b/src/model/settings.rs index 5a00dc425..8b8535f07 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -124,6 +124,7 @@ impl RootScreenType { RootScreenType::RootScreenDashPayProfile => 20, RootScreenType::RootScreenDashPayPayments => 21, RootScreenType::RootScreenDashPayProfileSearch => 22, + // 23 used to be the Masternode List Diff screen RootScreenType::RootScreenDashpay => 24, RootScreenType::RootScreenToolsGroveSTARKScreen => 25, RootScreenType::RootScreenToolsAddressBalanceScreen => 26, @@ -158,6 +159,7 @@ impl RootScreenType { 20 => Some(RootScreenType::RootScreenDashPayProfile), 21 => Some(RootScreenType::RootScreenDashPayPayments), 22 => Some(RootScreenType::RootScreenDashPayProfileSearch), + // 23 used to be the Masternode List Diff screen 24 => Some(RootScreenType::RootScreenDashpay), 25 => Some(RootScreenType::RootScreenToolsGroveSTARKScreen), 26 => Some(RootScreenType::RootScreenToolsAddressBalanceScreen), From 3599597b57842b051ea7c54ffafbe4d445678412 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:12:24 +0000 Subject: [PATCH 04/30] 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 --- docs/user-stories.md | 3 +- src/backend_task/tokens/mod.rs | 11 +- .../tokens/query_my_token_balances.rs | 234 +++++++++++++++--- src/context/contract_token_db.rs | 159 +++++++++++- 4 files changed, 363 insertions(+), 44 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index af4904cfb..6ce049130 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -932,7 +932,8 @@ As a user, I want to stop tracking a token balance for one of my identities so t - "Stop Tracking Balance" removes the chosen identity-token pair from the list. - The balance is un-watched so the background sync stops fetching it and the row does not reappear. -- An explicit "Refresh all my tokens" re-tracks every known token, restoring the row. +- The dismissal is remembered: "Refresh My Tokens" leaves the row gone, and only that identity-token pair is affected — other identities keep tracking the same token. +- The row comes back when the user asks for it again: re-importing the token restores it for every identity that dismissed it, and checking that one balance restores just that pair. --- diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index f1288fce4..8c34e4c87 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -108,8 +108,9 @@ pub enum TokenTask { QueryMyTokenBalances, QueryIdentityTokenBalance(IdentityTokenIdentifier), /// Stop tracking one `(identity, token)` balance: un-watch it upstream so - /// the background sync stops fetching it, then drop it from the My Tokens - /// ordering so the row disappears. + /// the background sync stops fetching it, drop it from the My Tokens + /// ordering so the row disappears, and record the dismissal so later + /// refreshes do not re-watch the pair. StopTrackingTokenBalance(IdentityTokenIdentifier), QueryDescriptionsByKeyword(String, Option), FetchTokenByContractId(Identifier), @@ -538,13 +539,17 @@ impl AppContext { } } TokenTask::SaveTokenLocally(token_info) => { + let token_id = token_info.token_id; self.insert_token( - &token_info.token_id, + &token_id, &token_info.token_name, token_info.token_configuration, &token_info.data_contract_id, token_info.token_position, )?; + // Importing a token is intent to track it, so it overrides an + // earlier "stop tracking" of the same token. + self.clear_untracked_token(&token_id)?; Ok(BackendTaskSuccessResult::SavedToken) } diff --git a/src/backend_task/tokens/query_my_token_balances.rs b/src/backend_task/tokens/query_my_token_balances.rs index ef63b92dd..a1936eb04 100644 --- a/src/backend_task/tokens/query_my_token_balances.rs +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -1,9 +1,13 @@ //! Refresh token balances from upstream. //! //! Balances are owned by the upstream `IdentitySyncManager`: DET registers -//! each local identity's watched-token list (its full local token registry), -//! forces a sync pass, then republishes the lock-free balance snapshot the My -//! Tokens screen reads. DET no longer fetches or caches balances itself. +//! each local identity's watched-token list, forces a sync pass, then +//! republishes the lock-free balance snapshot the My Tokens screen reads. DET +//! no longer fetches or caches balances itself. +//! +//! A watch set is the local token registry minus the `(identity, token)` pairs +//! the user stopped tracking. Upstream holds the watch set in memory only, so +//! the dismissals are persisted DET-side and re-applied on every refresh. use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; @@ -26,14 +30,10 @@ impl AppContext { return Err(TaskError::NoIdentitiesFound); } - // TODO: 'Stop tracking balance' undone by 'Refresh My Tokens' (re-registers full - // known-token registry × every identity). File an upstream feature request in - // dashpay/platform; fix is DET-side (persist dismissed pairs) since platform-wallet's - // token watch set is in-memory only. - let token_ids = self.known_token_ids()?; let identity_ids: Vec = identities.iter().map(|qi| qi.identity.id()).collect(); + let watch_sets = self.token_watch_sets(identity_ids)?; - self.refresh_upstream_token_balances(identity_ids, token_ids, &sender) + self.refresh_upstream_token_balances(watch_sets, &sender) .await?; Ok(BackendTaskSuccessResult::FetchedTokenBalances) @@ -43,26 +43,30 @@ impl AppContext { &self, _sdk: &Sdk, identity_id: Identifier, - _token_id: Identifier, + token_id: Identifier, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { + // Asking for a balance is intent to track it: undo any earlier "stop + // tracking" for this pair, otherwise the watch set would omit the very + // token the caller asked about. + self.clear_untracked_token_balance(token_id, identity_id)?; + // The upstream watch list is per-identity and replaced wholesale, so - // register the identity's full local token set (the requested token is - // part of it) rather than a single pair. - let token_ids = self.known_token_ids()?; - self.refresh_upstream_token_balances(vec![identity_id], token_ids, &sender) + // register the identity's whole watch set rather than a single pair. + let watch_sets = self.token_watch_sets(vec![identity_id])?; + self.refresh_upstream_token_balances(watch_sets, &sender) .await?; Ok(BackendTaskSuccessResult::FetchedTokenBalances) } /// Stop tracking one `(identity, token)` balance. Un-watches the pair in - /// the upstream sync loop so its background pass stops fetching the balance - /// and the pair leaves the published snapshot, then drops it from the saved - /// My Tokens ordering and nudges the UI to re-read the snapshot. The row - /// disappears immediately and stays gone for the background loop; the token - /// remains in DET's registry, so an explicit "Refresh all my tokens" still - /// re-watches it (that action deliberately re-tracks everything known). + /// the upstream sync loop so its background pass stops fetching the + /// balance and the pair leaves the published snapshot, records the + /// dismissal so later refreshes do not re-watch it, then drops it from the + /// saved My Tokens ordering and nudges the UI to re-read the snapshot. The + /// token stays in DET's registry: re-importing it, or explicitly checking + /// that identity's balance, tracks the pair again. pub async fn stop_tracking_token_balance( &self, identity_id: Identifier, @@ -72,6 +76,7 @@ impl AppContext { self.wallet_backend()? .unwatch_identity_token(identity_id, token_id) .await; + self.mark_token_balance_untracked(token_id, identity_id)?; self.remove_token_balance(token_id, identity_id)?; sender .send(TaskResult::Refresh) @@ -80,25 +85,43 @@ impl AppContext { Ok(BackendTaskSuccessResult::FetchedTokenBalances) } - /// Token ids in DET's local registry — the watch set every local identity - /// tracks upstream. - fn known_token_ids(&self) -> Result, TaskError> { - Ok(self.get_all_known_tokens()?.keys().copied().collect()) + /// The tokens DET watches upstream for each identity: every token in the + /// local registry, minus the pairs the user stopped tracking. + /// + /// Upstream's watch set is in-memory and replaced wholesale per identity, + /// so each refresh must rebuild it from DET's persisted state. + fn token_watch_sets( + &self, + identity_ids: Vec, + ) -> Result)>, TaskError> { + let token_ids: Vec = self.get_all_known_tokens()?.keys().copied().collect(); + let untracked = self.untracked_token_balances()?; + + Ok(identity_ids + .into_iter() + .map(|identity_id| { + let watched = token_ids + .iter() + .copied() + .filter(|token_id| !untracked.contains(&(*token_id, identity_id))) + .collect(); + (identity_id, watched) + }) + .collect()) } - /// Register each identity's watched tokens with upstream, force an - /// immediate sync pass, then republish DET's balance snapshot and nudge - /// the UI to re-read it. + /// Register each identity's watch set with upstream, force an immediate + /// sync pass, then republish DET's balance snapshot and nudge the UI to + /// re-read it. async fn refresh_upstream_token_balances( &self, - identity_ids: Vec, - token_ids: Vec, + watch_sets: Vec<(Identifier, Vec)>, sender: &crate::utils::egui_mpsc::SenderAsync, ) -> Result<(), TaskError> { let backend = self.wallet_backend()?; - for identity_id in identity_ids { + for (identity_id, token_ids) in watch_sets { backend - .register_identity_tokens(identity_id, token_ids.clone()) + .register_identity_tokens(identity_id, token_ids) .await; } backend.sync_token_balances_now().await; @@ -109,3 +132,152 @@ impl AppContext { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::utils::egui_mpsc::SenderAsync; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::data_contract::associated_token::token_configuration::TokenConfiguration; + use dash_sdk::dpp::data_contract::associated_token::token_configuration::v0::TokenConfigurationV0; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + use tokio::sync::mpsc::Receiver; + + /// Offline, wired context — real k/v store and wallet backend, no network. + /// The receiver is held so the `TaskResult::Refresh` nudge does not fail. + struct Fixture { + ctx: Arc, + sender: SenderAsync, + _rx: Receiver, + _dir: tempfile::TempDir, + } + + async fn fixture() -> Fixture { + let dir = tempfile::tempdir().expect("tempdir"); + let data_dir = dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + Arc::new(AtomicBool::new(false)), + ) + .expect("offline testnet AppContext"); + + let (tx, rx) = tokio::sync::mpsc::channel::(32); + let sender = SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender.clone()) + .await + .expect("wire wallet backend offline"); + + Fixture { + ctx, + sender, + _rx: rx, + _dir: dir, + } + } + + fn ident(byte: u8) -> Identifier { + Identifier::from([byte; 32]) + } + + /// Put a token in DET's local registry, as importing one would. + fn register_token(ctx: &AppContext, token_id: Identifier, alias: &str) { + ctx.insert_token( + &token_id, + alias, + TokenConfiguration::V0(TokenConfigurationV0::default_most_restrictive()), + &ident(200), + 0, + ) + .expect("insert token"); + } + + /// The tokens a balance refresh would re-register upstream for `identity`. + fn watched(ctx: &AppContext, identity: Identifier) -> Vec { + ctx.token_watch_sets(vec![identity]) + .expect("watch sets") + .pop() + .expect("one watch set per identity") + .1 + } + + /// The registry is sorted by alias, so watch sets come back in that order. + fn tokens(ctx: &AppContext) -> (Identifier, Identifier) { + let (alpha, beta) = (ident(1), ident(2)); + register_token(ctx, alpha, "Alpha"); + register_token(ctx, beta, "Beta"); + (alpha, beta) + } + + /// Dismissing a balance must survive "Refresh My Tokens": the pair stays + /// out of the identity's watch set, and only that identity is affected. + #[tokio::test] + async fn stopped_pair_is_not_rewatched_by_a_refresh() { + let f = fixture().await; + let (alpha, beta) = tokens(&f.ctx); + let (identity, other_identity) = (ident(10), ident(11)); + + f.ctx + .stop_tracking_token_balance(identity, alpha, f.sender.clone()) + .await + .expect("stop tracking"); + + assert_eq!(watched(&f.ctx, identity), vec![beta]); + assert_eq!(watched(&f.ctx, other_identity), vec![alpha, beta]); + } + + /// Explicitly checking a dismissed balance tracks that one pair again. + #[tokio::test] + async fn retracking_a_pair_restores_it_to_the_watch_set() { + let f = fixture().await; + let (alpha, beta) = tokens(&f.ctx); + let identity = ident(10); + + f.ctx + .stop_tracking_token_balance(identity, alpha, f.sender.clone()) + .await + .expect("stop tracking"); + f.ctx + .clear_untracked_token_balance(alpha, identity) + .expect("re-track pair"); + + assert_eq!(watched(&f.ctx, identity), vec![alpha, beta]); + } + + /// Re-importing a token tracks it again for every identity that dismissed + /// it — the "you can re-add it later" promise the remove dialog makes. + #[tokio::test] + async fn reimporting_a_token_retracks_it_for_every_identity() { + let f = fixture().await; + let (alpha, beta) = tokens(&f.ctx); + let (first, second) = (ident(10), ident(11)); + + for identity in [first, second] { + f.ctx + .stop_tracking_token_balance(identity, alpha, f.sender.clone()) + .await + .expect("stop tracking"); + } + f.ctx + .clear_untracked_token(&alpha) + .expect("re-import token"); + + assert_eq!(watched(&f.ctx, first), vec![alpha, beta]); + assert_eq!(watched(&f.ctx, second), vec![alpha, beta]); + } +} diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 56974b686..64c88e45f 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -20,6 +20,7 @@ use dash_sdk::dpp::serialization::{ use dash_sdk::platform::{DataContract, Identifier}; use dash_sdk::query_types::IndexMap; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; /// Key prefix for user-registered contract entries in the per-network /// wallet k/v store. The full key is `det:contract:` @@ -37,6 +38,11 @@ const TOKEN_KEY_PREFIX: &str = "det:token:"; /// k/v store, so each holds its own ordering). const TOKEN_ORDER_KEY: &str = "det:token_order:v1"; +/// Versioned key for the `(token_id, identity_id)` pairs the user stopped +/// tracking. Upstream's token watch set is in-memory only, so the dismissal +/// is persisted here and re-applied every time DET rebuilds a watch set. +const TOKEN_UNTRACKED_KEY: &str = "det:token_untracked:v1"; + fn contract_key(contract_id: &Identifier) -> String { format!( "{}{}", @@ -374,10 +380,10 @@ impl AppContext { Ok(result) } - /// Stop tracking a single `(identity, token)` pair in the My Tokens - /// ordering. Balances are owned upstream now, so this only prunes DET's - /// saved order list; the upstream sync loop still watches the token, so - /// the row reappears on the next balance refresh. + /// Drop a single `(identity, token)` pair from the My Tokens ordering. + /// Balances are owned upstream, so this only prunes DET's saved order + /// list — see [`Self::mark_token_balance_untracked`] for the dismissal + /// that keeps the pair out of future watch sets. pub fn remove_token_balance( &self, token_id: Identifier, @@ -388,6 +394,51 @@ impl AppContext { Ok(()) } + /// Every `(token_id, identity_id)` pair the user stopped tracking. + pub fn untracked_token_balances( + &self, + ) -> std::result::Result, TaskError> { + read_untracked(&self.det_kv()?) + } + + /// Record a `(token, identity)` pair as no longer tracked, so balance + /// refreshes stop re-watching it upstream. Idempotent. + pub fn mark_token_balance_untracked( + &self, + token_id: Identifier, + identity_id: Identifier, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let mut pairs = read_untracked(&kv)?; + if pairs.insert((token_id, identity_id)) { + write_untracked(&kv, &pairs)?; + } + Ok(()) + } + + /// Track a previously dismissed `(token, identity)` pair again. + /// Idempotent — a pair that was never dismissed is left alone. + pub fn clear_untracked_token_balance( + &self, + token_id: Identifier, + identity_id: Identifier, + ) -> std::result::Result<(), TaskError> { + let kv = self.det_kv()?; + let mut pairs = read_untracked(&kv)?; + if pairs.remove(&(token_id, identity_id)) { + write_untracked(&kv, &pairs)?; + } + Ok(()) + } + + /// Track a token again for every identity that dismissed it. Idempotent. + pub fn clear_untracked_token( + &self, + token_id: &Identifier, + ) -> std::result::Result<(), TaskError> { + clear_untracked_token_in(&self.det_kv()?, token_id) + } + /// Insert (or refresh) a token entry in the local registry. Balances are /// owned upstream now; the upstream sync loop fetches them once the /// identity's watch list is registered, so no balance row is seeded here. @@ -429,14 +480,15 @@ impl AppContext { Ok(()) } - /// Remove a token from the registry and drop it from the saved order. - /// Per-identity balances are owned upstream, so there is nothing local to - /// cascade-delete. + /// Remove a token from the registry and drop it from the saved order and + /// the dismissal list. Per-identity balances are owned upstream, so there + /// is nothing local to cascade-delete. pub fn remove_token(&self, token_id: &Identifier) -> std::result::Result<(), TaskError> { let kv = self.det_kv()?; kv.delete(DetScope::Global, &token_key(token_id)) .map_err(token_err)?; prune_token_order(&kv, |(t, _)| t != token_id)?; + clear_untracked_token_in(&kv, token_id)?; Ok(()) } @@ -596,8 +648,8 @@ impl AppContext { .collect()) } - /// Devnet-only sweep: drop the token registry, every balance entry - /// and the saved order. No-op on non-devnet networks. + /// Devnet-only sweep: drop the token registry, every balance entry, + /// the saved order and the dismissal list. No-op on non-devnet networks. pub fn delete_all_local_tokens_in_devnet(&self) -> std::result::Result<(), TaskError> { if self.network != Network::Devnet { return Ok(()); @@ -612,6 +664,8 @@ impl AppContext { // Balances are owned upstream now — nothing local to wipe. kv.delete(DetScope::Global, TOKEN_ORDER_KEY) .map_err(token_err)?; + kv.delete(DetScope::Global, TOKEN_UNTRACKED_KEY) + .map_err(token_err)?; Ok(()) } @@ -659,6 +713,50 @@ fn decode_token_config(bytes: &[u8]) -> std::result::Result std::result::Result, TaskError> { + let Some(payload): Option> = kv + .get(DetScope::Global, TOKEN_UNTRACKED_KEY) + .map_err(token_err)? + else { + return Ok(BTreeSet::new()); + }; + Ok(payload + .into_iter() + .map(|(t, i)| (Identifier::from(t), Identifier::from(i))) + .collect()) +} + +fn write_untracked( + kv: &DetKv, + pairs: &BTreeSet<(Identifier, Identifier)>, +) -> std::result::Result<(), TaskError> { + let payload: Vec<([u8; 32], [u8; 32])> = pairs + .iter() + .map(|(t, i)| (t.to_buffer(), i.to_buffer())) + .collect(); + kv.put(DetScope::Global, TOKEN_UNTRACKED_KEY, &payload) + .map_err(token_err) +} + +/// Drop every dismissal recorded for `token_id`, whichever identity made it. +/// Writes back only when the set actually shrinks. +fn clear_untracked_token_in( + kv: &DetKv, + token_id: &Identifier, +) -> std::result::Result<(), TaskError> { + let mut pairs = read_untracked(kv)?; + let before = pairs.len(); + pairs.retain(|(t, _)| t != token_id); + if pairs.len() != before { + write_untracked(kv, &pairs)?; + } + Ok(()) +} + /// Filter the stored token-order list and write it back when the filter /// drops any entries. No-op when no order list exists yet. fn prune_token_order(kv: &DetKv, keep: F) -> std::result::Result<(), TaskError> @@ -818,4 +916,47 @@ mod tests { kv.get(DetScope::Global, TOKEN_ORDER_KEY).unwrap().unwrap(); assert_eq!(got, payload); } + + // ---------------------------------------------------------------- + // Untracked pairs: dismissals persist so refreshes stop re-watching. + // ---------------------------------------------------------------- + + #[test] + fn untracked_pairs_read_as_empty_before_anything_is_dismissed() { + let kv = empty_kv(); + assert!(read_untracked(&kv).unwrap().is_empty()); + } + + #[test] + fn untracked_pairs_round_trip_through_the_kv_store() { + let kv = empty_kv(); + let pairs = BTreeSet::from([(ident(1), ident(10)), (ident(2), ident(20))]); + write_untracked(&kv, &pairs).unwrap(); + assert_eq!(read_untracked(&kv).unwrap(), pairs); + } + + #[test] + fn clearing_a_token_drops_its_dismissals_for_every_identity() { + let kv = empty_kv(); + let cleared = ident(1); + let kept = (ident(2), ident(10)); + write_untracked( + &kv, + &BTreeSet::from([(cleared, ident(10)), (cleared, ident(11)), kept]), + ) + .unwrap(); + + clear_untracked_token_in(&kv, &cleared).unwrap(); + + assert_eq!(read_untracked(&kv).unwrap(), BTreeSet::from([kept])); + } + + #[test] + fn clearing_an_undismissed_token_is_a_noop() { + let kv = empty_kv(); + let pairs = BTreeSet::from([(ident(1), ident(10))]); + write_untracked(&kv, &pairs).unwrap(); + clear_untracked_token_in(&kv, &ident(9)).unwrap(); + assert_eq!(read_untracked(&kv).unwrap(), pairs); + } } From a20928844e0a4802e875ad85b1c96769e9b4e927 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:17:01 +0000 Subject: [PATCH 05/30] feat(shielded): restore shielded receive-address view and copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/user-stories.md | 11 +- src/context/mod.rs | 33 +++++ src/context/wallet_lifecycle/bootstrap.rs | 53 +++++++ src/context/wallet_lifecycle/removal.rs | 7 + src/context/wallet_lifecycle/tests.rs | 128 +++++++++++++++++ src/mcp/tools/shielded.rs | 5 +- src/model/address.rs | 97 +++++++++++++ src/ui/wallets/shielded_tab.rs | 164 +++++++++++++++++++--- 8 files changed, 467 insertions(+), 31 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index af4904cfb..b6c3413e2 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -262,14 +262,15 @@ As a user, I want the app to tell me when a wallet's totals don't add up after s - The banner is not repeated on every later sync while the same difference persists, and it clears on its own once the totals agree again. - The check runs for all loaded wallets, not just the one currently on screen. -### WAL-028: View and copy my shielded receive address [Gap] +### WAL-028: View and copy my shielded receive address [Implemented] **Persona:** Jordan -As a developer, I want to view and copy my own shielded receive address (and generate additional diversified addresses) so that I can give it to another party to receive a private transfer. +As a developer, I want to view and copy my own shielded receive address so that I can give it to another party to receive a private transfer. -- The Shielded tab shows the wallet's default shielded address once the wallet is unlocked and synced. -- A copy-to-clipboard control is available, and additional diversified addresses can be generated on demand. -- Currently a gap: the Shielded tab renders only a placeholder ("Shielded address available after wallet unlock and sync"); the underlying `WalletBackend::shielded_default_address` read is async-only and not surfaced synchronously to the UI, so no address, copy control, or diversified-address generator is shown. +- The Shielded tab shows the wallet's shielded address (Orchard account 0) once the wallet's shielded keys are bound at unlock; until then it says the address appears after unlock. +- The address is copied to the clipboard by clicking either the address itself or the Copy button; the full address is copied even though the display is truncated. +- The address is published to the UI through a frame-safe snapshot written on the backend side after `ensure_shielded_bound`, sourced from the upstream key slot the shielded coordinator scans with — never re-derived in DET. +- Generating additional diversified addresses remains a gap: upstream `platform-wallet` exposes no per-diversifier-index accessor (only `shielded_default_address` / `shielded_default_addresses`), so a "+" control cannot be wired without either duplicating Orchard key derivation outside the coordinator seam or stranding funds in a ZIP-32 account the single-account spend path cannot spend from. ### WAL-029: Inspect shielded note details [Gap] **Persona:** Jordan diff --git a/src/context/mod.rs b/src/context/mod.rs index 2ea70035e..dc5b31e8a 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -147,6 +147,23 @@ pub struct AppContext { /// first completed sync delivers a balance. Must never be written from the frame /// loop (Nagatha ruling: no `block_in_place`/`block_on` on the UI thread). pub(crate) shielded_balances: Arc>>, + /// Frame-safe shielded receive-address snapshot (Bech32m, Orchard account 0). + /// + /// The read side of the receive-address bridge: written on the async backend + /// side by [`Self::cache_shielded_receive_address`] right after the wallet's + /// Orchard keys are bound, read synchronously in the frame loop via + /// [`Self::shielded_receive_address`]. Starts empty — the Shielded tab shows + /// its "not ready yet" copy until a bind completes. + /// + /// **Funds safety:** the address is produced by 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 actually detect notes for. + /// Evicted on wallet removal so a re-imported seed can never surface a + /// removed wallet's address. Must never be written from the frame loop + /// (Nagatha ruling: no `block_in_place`/`block_on` on the UI thread). + pub(crate) shielded_addresses: Arc>>, /// Frame-safe platform-address balance snapshot (duffs, summed across owned addresses). /// /// Written by `on_platform_address_sync_completed` in [`EventBridge`] after each @@ -370,6 +387,7 @@ impl AppContext { ), platform_protocol_version: AtomicU32::new(0), shielded_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), + shielded_addresses: Arc::new(Mutex::new(std::collections::HashMap::new())), platform_balances: Arc::new(Mutex::new(std::collections::HashMap::new())), platform_sync_cursors: Arc::new(Mutex::new(std::collections::HashMap::new())), egui_ctx, @@ -577,6 +595,21 @@ impl AppContext { .unwrap_or(0) } + /// Synchronous, frame-safe reader for the wallet's shielded receive address + /// (Bech32m, Orchard account 0). Returns `None` until the wallet's shielded + /// keys are bound — the Shielded tab renders its "not ready yet" copy then. + /// + /// The read side of the push snapshot; the write side is + /// [`Self::cache_shielded_receive_address`], driven from the JIT bootstrap + /// once `ensure_shielded_bound` succeeds. Safe to call from the egui frame + /// loop — no blocking I/O, no async. + pub fn shielded_receive_address(&self, seed_hash: &WalletSeedHash) -> Option { + self.shielded_addresses + .lock() + .ok() + .and_then(|map| map.get(seed_hash).cloned()) + } + /// Synchronous read of the frame-safe platform-address balance for `seed_hash`. /// /// Returns the total platform balance in **duffs**, summed across all OWNED platform diff --git a/src/context/wallet_lifecycle/bootstrap.rs b/src/context/wallet_lifecycle/bootstrap.rs index 4c5300747..42c133b7d 100644 --- a/src/context/wallet_lifecycle/bootstrap.rs +++ b/src/context/wallet_lifecycle/bootstrap.rs @@ -138,6 +138,10 @@ impl AppContext { %error, "Shielded bind deferred; will retry on next unlock" ); + } else { + // Keys are bound, so the receive address is now readable + // from the upstream key slot. Cache it for the frame loop. + self.cache_shielded_receive_address(&backend, &seed_hash).await; } // Register every established contact's DIP-15 receiving // account so SPV watches the addresses each contact pays us @@ -169,6 +173,55 @@ impl AppContext { } } + /// Publish `seed_hash`'s shielded receive address into the frame-safe + /// snapshot the Shielded tab reads ([`AppContext::shielded_receive_address`]). + /// + /// Reads Orchard **account 0** — the only account DET binds and the only one + /// its spend path (`shielded_transfer(.., 0, ..)`) can spend from — through + /// the upstream-owned key slot, so the address shown is derived from the very + /// `OrchardKeySet` the coordinator scans with. Runs on the async backend + /// side, never in the frame loop. + /// + /// Best-effort: an unbound wallet or a malformed payload leaves the snapshot + /// untouched and the tab keeps its "not ready yet" copy rather than showing a + /// stale or unusable address. + pub(super) async fn cache_shielded_receive_address( + &self, + backend: &WalletBackend, + seed_hash: &WalletSeedHash, + ) { + let raw = match backend.shielded_default_address(seed_hash, 0).await { + Ok(Some(raw)) => raw, + Ok(None) => { + tracing::debug!( + wallet = %hex::encode(seed_hash), + "Shielded receive address unavailable; wallet has no bound Orchard account 0" + ); + return; + } + Err(error) => { + tracing::debug!( + wallet = %hex::encode(seed_hash), + %error, + "Shielded receive address read failed; will retry on next boot/unlock" + ); + return; + } + }; + match crate::model::address::encode_shielded_address(&raw, self.network) { + Ok(address) => { + if let Ok(mut cache) = self.shielded_addresses.lock() { + cache.insert(*seed_hash, address); + } + } + Err(error) => tracing::warn!( + wallet = %hex::encode(seed_hash), + %error, + "Shielded receive address could not be encoded; leaving it unset" + ), + } + } + /// Register every DET-known, wallet-owned identity for `seed_hash` into the /// upstream `IdentityManager`, so identity ops that look identities up there /// (currently: top-up) find them instead of raising `IdentityNotFound`. diff --git a/src/context/wallet_lifecycle/removal.rs b/src/context/wallet_lifecycle/removal.rs index d3e91372a..5aa95f0af 100644 --- a/src/context/wallet_lifecycle/removal.rs +++ b/src/context/wallet_lifecycle/removal.rs @@ -28,6 +28,13 @@ impl AppContext { balances.remove(seed_hash); } + // Evict the receive-address snapshot for the same reason, and with a + // sharper edge: a stale address left behind here is one a user could + // copy and be paid at, so it must not outlive the wallet that owns it. + if let Ok(mut addresses) = self.shielded_addresses.lock() { + addresses.remove(seed_hash); + } + // Permanently wipe the wallet's secret-bearing state so removal is not // recoverable: the encrypted seed-envelope vault, the session secret // cache, the wallet-meta sidecar, and the plaintext shielded-note rows diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 81b74ba86..5da59f967 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1143,6 +1143,134 @@ async fn remove_wallet_wipes_seed_envelope() { backend.shutdown().await; } +/// The receive-address snapshot is empty until a bind publishes into it, so a +/// wallet that is locked or not yet bound reports `None` and the Shielded tab +/// falls back to its "not ready yet" copy instead of rendering a wrong address. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shielded_receive_address_is_none_before_bind() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xC4u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + let backend = ctx.wallet_backend().expect("backend wired"); + + assert_eq!( + ctx.shielded_receive_address(&seed_hash), + None, + "an unregistered, unbound wallet must not surface any receive address" + ); + + backend.shutdown().await; +} + +/// The published receive address is the Orchard **account-0 external** address +/// of the wallet's own seed — the exact key material `bind_shielded` hands the +/// coordinator to scan with, and the only account DET's spend path can spend +/// from. +/// +/// This is the funds-safety contract of the whole bridge: we assert the cached +/// string against an independently ZIP-32-derived expectation, so a future +/// change that publishes some *other* account, scope, or diversifier — an +/// address the wallet could be paid at but never detect or spend — fails here +/// rather than silently costing a user their money. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cache_shielded_receive_address_publishes_bound_account_zero_address() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xD7u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + // Mirror `bootstrap_wallet_addresses_jit`'s ordering: a wallet must be + // registered upstream before its Orchard keys can bind. + backend + .ensure_upstream_registered(&seed_hash, &seed) + .await + .expect("register wallet upstream"); + backend + .ensure_shielded_bound(&seed_hash, &seed) + .await + .expect("bind Orchard keys offline"); + + ctx.cache_shielded_receive_address(&backend, &seed_hash) + .await; + + let expected_raw = + platform_wallet::wallet::shielded::OrchardKeySet::from_seed(&seed, Network::Testnet, 0) + .expect("ZIP-32 derivation") + .address_at(0) + .to_raw_address_bytes(); + let expected = crate::model::address::encode_shielded_address(&expected_raw, Network::Testnet) + .expect("encode expected address"); + + assert_eq!( + ctx.shielded_receive_address(&seed_hash), + Some(expected), + "the receive address must be account 0's external address, derived from the bound keys" + ); + + backend.shutdown().await; +} + +/// Removing a wallet evicts its shielded receive address. The seed hash is +/// deterministic, so without eviction a re-import of the same phrase — or any +/// later read — could surface a removed wallet's address as a live payment +/// destination. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remove_wallet_evicts_shielded_receive_address() { + let (ctx, sender, _tmp) = offline_testnet_context(); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + + let seed = [0xE9u8; 64]; + let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) + .expect("build wallet"); + let seed_hash = wallet.seed_hash(); + ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) + .expect("register wallet"); + + let backend = ctx.wallet_backend().expect("backend wired"); + // Mirror `bootstrap_wallet_addresses_jit`'s ordering: a wallet must be + // registered upstream before its Orchard keys can bind. + backend + .ensure_upstream_registered(&seed_hash, &seed) + .await + .expect("register wallet upstream"); + backend + .ensure_shielded_bound(&seed_hash, &seed) + .await + .expect("bind Orchard keys offline"); + ctx.cache_shielded_receive_address(&backend, &seed_hash) + .await; + assert!( + ctx.shielded_receive_address(&seed_hash).is_some(), + "precondition: the address must be published before removal" + ); + + ctx.remove_wallet(&seed_hash).expect("remove wallet"); + + assert_eq!( + ctx.shielded_receive_address(&seed_hash), + None, + "the receive address must be evicted on wallet removal" + ); + + backend.shutdown().await; +} + /// Removing a wallet evicts its shielded balance snapshot from /// `AppContext::shielded_balances`. The seed hash is deterministic from the /// seed, so without eviction a re-import of the same recovery phrase would diff --git a/src/mcp/tools/shielded.rs b/src/mcp/tools/shielded.rs index 47d6a486a..ecbef2c9e 100644 --- a/src/mcp/tools/shielded.rs +++ b/src/mcp/tools/shielded.rs @@ -753,9 +753,8 @@ impl AsyncTool for ShieldedAddressGet { .to_owned(), })?; - let address = dash_sdk::dpp::address_funds::OrchardAddress::from_raw_bytes(&raw) - .map_err(|e| McpToolError::Internal(format!("Failed to encode shielded address: {e}")))? - .to_bech32m_string(ctx.network()); + let address = crate::model::address::encode_shielded_address(&raw, ctx.network()) + .map_err(|e| McpToolError::Internal(e.to_string()))?; Ok(ShieldedAddressGetOutput { address }) } diff --git a/src/model/address.rs b/src/model/address.rs index 861125ff0..7f612d8ba 100644 --- a/src/model/address.rs +++ b/src/model/address.rs @@ -345,6 +345,33 @@ pub fn parse_shielded_recipient(input: &str) -> Option> { (bytes.len() == SHIELDED_ADDRESS_RAW_LEN).then_some(bytes) } +/// A raw Orchard payload that does not decode to a valid shielded address. +/// +/// Raised when the 43-byte payload is not a well-formed Orchard address (its +/// `pk_d` is not a valid curve point), so it cannot be rendered as bech32m. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("This shielded address could not be read. Please reopen the wallet and try again.")] +pub struct InvalidShieldedAddress; + +/// Encode a raw 43-byte Orchard payload as its canonical Bech32m string +/// (`dash1z…` on mainnet, `tdash1z…` elsewhere). +/// +/// The inverse of [`parse_shielded_recipient`] and the single source of truth +/// for rendering a shielded address the wallet backend hands us as raw bytes. +/// +/// # Errors +/// +/// Returns [`InvalidShieldedAddress`] when `raw` is not a well-formed Orchard +/// address. +pub fn encode_shielded_address( + raw: &[u8; SHIELDED_ADDRESS_RAW_LEN], + network: Network, +) -> Result { + dash_sdk::dpp::address_funds::OrchardAddress::from_raw_bytes(raw) + .map(|addr| addr.to_bech32m_string(network)) + .map_err(|_| InvalidShieldedAddress) +} + /// Truncate an address string for display, showing a prefix and suffix /// separated by an ellipsis. /// @@ -410,6 +437,76 @@ mod tests { assert_eq!(parse_shielded_recipient("dash1qexampleplatform"), None); } + // --- encode_shielded_address (raw -> bech32m) --- + // + // Vectors are derived through the real upstream ZIP-32 path + // (`OrchardKeySet::from_seed`) rather than fabricated bytes: an Orchard + // payload embeds a curve point, so arbitrary bytes are not a valid address + // and would not exercise the encoder honestly. + + fn test_shielded_raw_address(network: Network) -> [u8; SHIELDED_ADDRESS_RAW_LEN] { + let keys = + platform_wallet::wallet::shielded::OrchardKeySet::from_seed(&[7u8; 64], network, 0) + .expect("ZIP-32 derivation from a valid test seed"); + keys.address_at(0).to_raw_address_bytes() + } + + #[test] + fn encode_shielded_address_round_trips_with_parse() { + let raw = test_shielded_raw_address(Network::Testnet); + let encoded = + encode_shielded_address(&raw, Network::Testnet).expect("valid Orchard payload encodes"); + // The encoder and the existing parser are exact inverses — a receive + // address we render must parse back to the very bytes it came from, + // or a user could copy an address that does not match wallet state. + assert_eq!( + parse_shielded_recipient(&encoded).as_deref(), + Some(raw.as_slice()), + ); + } + + #[test] + fn encode_shielded_address_uses_network_prefix() { + let mainnet = encode_shielded_address( + &test_shielded_raw_address(Network::Mainnet), + Network::Mainnet, + ) + .expect("valid Orchard payload encodes"); + let testnet = encode_shielded_address( + &test_shielded_raw_address(Network::Testnet), + Network::Testnet, + ) + .expect("valid Orchard payload encodes"); + + assert!(mainnet.starts_with("dash1z"), "got {mainnet}"); + assert!(testnet.starts_with("tdash1z"), "got {testnet}"); + // The output satisfies the shielded network validator the send path + // enforces, so an address we display is one the app will accept back. + assert!(validate_orchard_address_for_network(&mainnet, Network::Mainnet).is_ok()); + assert!(validate_orchard_address_for_network(&testnet, Network::Testnet).is_ok()); + assert_eq!(AddressKind::detect(&testnet), Some(AddressKind::Shielded)); + } + + #[test] + fn encode_shielded_address_rejects_malformed_payload() { + // All-zero bytes are not a valid Orchard address (pk_d is not a valid + // curve point) — the encoder must reject rather than emit a string + // that no one can pay to. + assert_eq!( + encode_shielded_address(&[0u8; SHIELDED_ADDRESS_RAW_LEN], Network::Testnet), + Err(InvalidShieldedAddress), + ); + } + + #[test] + fn invalid_shielded_address_message_is_user_facing() { + // Everyday-User rule: what happened + what to do, no jargon. + assert_eq!( + InvalidShieldedAddress.to_string(), + "This shielded address could not be read. Please reopen the wallet and try again.", + ); + } + #[test] fn address_kind_display_names() { assert_eq!(AddressKind::Core.display_name(), "Wallet address"); diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 26925d0d4..1eb41ac1b 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -3,10 +3,12 @@ use crate::backend_task::BackendTask; use crate::backend_task::migration::MigrationTask; use crate::context::AppContext; use crate::context::migration_status::{MigrationState, MigrationStep}; +use crate::model::address::truncate_address; use crate::model::fee_estimation::format_credits_as_dash; use crate::model::wallet::WalletSeedHash; use crate::ui::ScreenType; use crate::ui::components::wallet_unlock_popup::wallet_needs_unlock; +use crate::ui::helpers::copy_text_to_clipboard; use crate::ui::theme::DashColors; use crate::ui::wallets::send_screen::SendFlow; use eframe::egui::{self, Ui}; @@ -26,6 +28,17 @@ pub const SHIELDED_LOCK_ICON: &str = "\u{1F512}"; // 🔒 pub const SHIELDED_VERIFIED_ICON: &str = "\u{2714}"; // ✔ pub const SHIELDED_RETRY_MIGRATION_LABEL: &str = "Retry shielded migration"; pub const SHIELDED_SKIP_MIGRATION_LABEL: &str = "Skip for now"; +/// Receive-address section copy. Each is a complete sentence or a standalone +/// label so the i18n pass extracts it as one translation unit; `pub` so the +/// tests assert against the exact strings the UI renders. +pub const SHIELDED_ADDRESS_HEADING: &str = "Shielded Address"; +pub const SHIELDED_ADDRESS_HINT: &str = "Share this address to receive a private transfer."; +pub const SHIELDED_ADDRESS_PENDING_LABEL: &str = + "Your shielded address appears here once the wallet is unlocked."; +pub const SHIELDED_ADDRESS_COPY_LABEL: &str = "Copy"; +pub const SHIELDED_ADDRESS_COPIED_LABEL: &str = "Shielded address copied to the clipboard."; +pub const SHIELDED_ADDRESS_COPY_FAILED_LABEL: &str = + "The address could not be copied. Select the address text and copy it manually."; pub const SHIELDED_MIGRATION_ERROR_LABEL: &str = "Shielded data could not be migrated. Try again, or skip and use the rest of your wallet."; pub const SHIELDED_TAB_SKIPPED_LABEL: &str = @@ -82,8 +95,10 @@ pub struct ShieldedTabView { tree_synced: bool, /// Pending backend task to dispatch on next ui() call (e.g., sync after Resync). pending_task: Option, - /// Number of diversified addresses generated (always >= 1). - address_count: u32, + /// The wallet's shielded receive address (Bech32m), mirrored each frame from + /// the frame-safe [`AppContext`] snapshot. `None` until the wallet's Orchard + /// keys are bound. + shielded_address: Option, /// J-3: session-local flag set when the user clicks "Skip for now" /// on the sidecar-failure banner. Suppresses the retry banner and /// locks the tab until the app restarts. @@ -103,7 +118,7 @@ impl ShieldedTabView { is_initialized: false, tree_synced: false, pending_task: None, - address_count: 1, + shielded_address: None, sidecar_skipped: false, } } @@ -145,7 +160,9 @@ impl ShieldedTabView { self.initializing = false; self.syncing = false; self.pending_task = None; - self.address_count = 1; + // Drop the previous wallet's address immediately rather than + // letting it linger for a frame — it is a payment destination. + self.shielded_address = None; // Skip-for-now is session-scoped to the wallet; a new // wallet starts with the retry banner re-enabled. self.sidecar_skipped = false; @@ -168,17 +185,20 @@ impl ShieldedTabView { .unwrap_or(AppAction::None) } - /// Sync local display state from the push balance snapshot and the - /// upstream coordinator. + /// Sync local display state from the push snapshots and the upstream + /// coordinator. /// /// The upstream `platform-wallet` coordinator owns all Orchard state (keys, - /// sync progress, note tree). Balance is read from the frame-safe push - /// snapshot; `is_initialized` / `tree_synced` are set true whenever the - /// wallet backend is wired so spend buttons are enabled. Fine-grained sync - /// progress arrives through the push-based [`ConnectionStatus`]. + /// sync progress, note tree). Balance and receive address are read from the + /// frame-safe push snapshots; `is_initialized` / `tree_synced` are set true + /// whenever the wallet backend is wired so spend buttons are enabled. + /// Fine-grained sync progress arrives through the push-based + /// [`ConnectionStatus`](crate::context::connection_status::ConnectionStatus). fn refresh_from_backend_state(&mut self) { - // Balance: use the frame-safe push snapshot (no lock in frame loop). + // Balance and address: frame-safe push snapshots, no async in the frame + // loop. Both are written on the backend side once Orchard keys bind. self.shielded_balance = self.app_context.shielded_balance_credits(&self.seed_hash); + self.shielded_address = self.app_context.shielded_receive_address(&self.seed_hash); // Treat the wallet as initialized and the tree as synced whenever the // backend is available — the coordinator resyncs Orchard state from @@ -190,29 +210,79 @@ impl ShieldedTabView { } } - /// Render the collapsible shielded addresses section with a table of all - /// diversified addresses. + /// Render the shielded receive-address section: the address, a hint, and a + /// copy control. Open by default — receiving a private transfer is the + /// reason to visit this tab, so the address must not be a click away. + /// + /// Shows Orchard account 0, the only account DET binds and the only one its + /// spend path can spend from. Displaying any other account would offer a + /// destination whose funds the app could not move. + /// + // TODO: offer additional diversified addresses ("+") once upstream + // platform-wallet exposes a per-index accessor. At the pinned revision the + // only shielded address APIs are `shielded_default_address(account)` / + // `shielded_default_addresses()`; `OrchardKeySet::address_at(index)` is + // reachable only through 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. fn render_address_section(&mut self, ui: &mut Ui, dark_mode: bool) { - let dev_mode = self.app_context.is_developer_mode(); - let header = egui::CollapsingHeader::new( - RichText::new("Shielded Addresses") + RichText::new(SHIELDED_ADDRESS_HEADING) .size(16.0) .color(DashColors::text_primary(dark_mode)), ) .id_salt("shielded_addresses") - .default_open(dev_mode); + .default_open(true); header.show(ui, |ui| { - // Shielded addresses are derived by the upstream platform-wallet - // coordinator; the default address is available via the async - // WalletBackend::shielded_default_address API. - // TODO: render the default address here once a synchronous read is - // exposed through the push snapshot. + let Some(address) = self.shielded_address.clone() else { + ui.label( + RichText::new(SHIELDED_ADDRESS_PENDING_LABEL) + .color(DashColors::text_secondary(dark_mode)), + ); + return; + }; + ui.label( - RichText::new("Shielded address available after wallet unlock and sync.") + RichText::new(SHIELDED_ADDRESS_HINT) + .size(12.0) .color(DashColors::text_secondary(dark_mode)), ); + ui.add_space(4.0); + + let copy_requested = ui + .horizontal(|ui| { + // Truncated for layout; the full address is always one hover + // away and the clipboard always receives the full string. + let shown = truncate_address(&address, 20, 12); + let clicked_address = ui + .add( + egui::Label::new( + RichText::new(shown) + .monospace() + .color(DashColors::text_primary(dark_mode)), + ) + .sense(egui::Sense::click()), + ) + .on_hover_text(&address) + .clicked(); + let clicked_button = ui.button(SHIELDED_ADDRESS_COPY_LABEL).clicked(); + clicked_address || clicked_button + }) + .inner; + + if copy_requested { + match copy_text_to_clipboard(&address) { + Ok(()) => { + self.success_message = Some(SHIELDED_ADDRESS_COPIED_LABEL.to_string()); + } + Err(error) => { + tracing::warn!(%error, "Shielded address clipboard copy failed"); + self.error_message = Some(SHIELDED_ADDRESS_COPY_FAILED_LABEL.to_string()); + } + } + } }); } @@ -695,6 +765,54 @@ mod tests { ); } + /// WAL-028 — the receive-address copy is i18n-clean: complete sentences for + /// the prose, a bare label for the button. The pending copy is what a + /// locked / not-yet-bound wallet shows in place of an address, so it must + /// never read as if an address were present. + #[test] + fn shielded_address_section_copy_is_i18n_clean() { + for sentence in [ + SHIELDED_ADDRESS_HINT, + SHIELDED_ADDRESS_PENDING_LABEL, + SHIELDED_ADDRESS_COPIED_LABEL, + SHIELDED_ADDRESS_COPY_FAILED_LABEL, + ] { + assert!( + sentence.ends_with('.'), + "user-facing copy must be a complete sentence: {sentence}" + ); + } + assert!(!SHIELDED_ADDRESS_HEADING.is_empty()); + assert!(!SHIELDED_ADDRESS_COPY_LABEL.is_empty()); + // The failure copy must give the user a way out on their own — no + // dead end, no "contact support". + assert!( + SHIELDED_ADDRESS_COPY_FAILED_LABEL.contains("manually"), + "the copy-failure message must offer a self-service fallback", + ); + } + + /// The address the tab renders is the one the clipboard receives — the + /// truncation is display-only. A user who copies must get a payable + /// address, never the ellipsised form. + #[test] + fn displayed_address_is_truncated_but_copy_uses_the_full_string() { + let address = "tdash1z".to_string() + &"q".repeat(70); + let shown = truncate_address(&address, 20, 12); + + assert!( + shown.contains("..."), + "long addresses are truncated on screen" + ); + assert!(shown.len() < address.len()); + // The full string stays intact for the clipboard and the hover text. + assert!(address.starts_with("tdash1z")); + assert_eq!( + crate::model::address::AddressKind::detect(&address), + Some(crate::model::address::AddressKind::Shielded), + ); + } + /// The Verified badge follows the same icon + text rule so /// greyscale viewers see the same affirmation as colour users. #[test] From 6724ef9cd218947f6751c76d919c5e7dd98e75d4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:17:44 +0000 Subject: [PATCH 06/30] feat(identity-hub): wire Contacts actions, real contact list, alias, and pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/dashpay.rs | 15 + src/backend_task/dashpay/contact_requests.rs | 254 ++++++++ src/backend_task/dashpay/errors.rs | 8 + src/backend_task/mod.rs | 1 + src/ui/identity/contacts.rs | 637 ++++++++++--------- src/ui/identity/hub_screen.rs | 40 ++ src/ui/identity/settings.rs | 137 ++++ src/ui/state/contacts_view.rs | 419 ++++++++++++ src/ui/state/mod.rs | 1 + src/wallet_backend/dashpay.rs | 12 + 10 files changed, 1217 insertions(+), 307 deletions(-) create mode 100644 src/ui/state/contacts_view.rs diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 787a8ab5d..f1dfe8725 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -81,6 +81,15 @@ pub enum DashPayTask { identity: QualifiedIdentity, request_id: Identifier, }, + /// Withdraw a still-pending contact request this identity sent. + /// + /// Platform cannot delete a `contactRequest` document, so the handler + /// broadcasts a hidden `contactInfo` and records the withdrawal locally — + /// see [`contact_requests::cancel_contact_request`]. + CancelContactRequest { + identity: QualifiedIdentity, + request_id: Identifier, + }, LoadPaymentHistory { identity: QualifiedIdentity, }, @@ -201,6 +210,12 @@ impl AppContext { } => Ok( contact_requests::reject_contact_request(self, sdk, identity, request_id).await?, ), + DashPayTask::CancelContactRequest { + identity, + request_id, + } => Ok( + contact_requests::cancel_contact_request(self, sdk, identity, request_id).await?, + ), DashPayTask::LoadPaymentHistory { identity } => { let identity_id = identity.identity.id(); // Refresh-style action: kick upstream before reading so the diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 755e0d9e3..eb0bb9edd 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -149,6 +149,19 @@ pub async fn load_contact_requests( } }); + // Drop requests the user has already resolved locally (declined an incoming + // one, or cancelled a sent one). Platform keeps contactRequest documents + // forever, so without this the resolved row reappears on every reload. + let backend = app_context.wallet_backend().ok(); + retain_unresolved( + &mut incoming, + &mut outgoing, + |counterparty| match &backend { + Some(backend) => backend.dashpay_is_rejected(&identity_id, counterparty), + None => false, + }, + ); + tracing::info!( "After filtering: {} incoming, {} outgoing contact requests", incoming.len(), @@ -158,6 +171,44 @@ pub async fn load_contact_requests( Ok(BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing }) } +/// The recipient (`toUserId`) of a contact-request document. +fn recipient_of(doc: &Document) -> Option { + doc.properties() + .get("toUserId") + .and_then(|v| v.to_identifier().ok()) +} + +/// Drop every request whose counterparty the user has already resolved, per +/// `is_resolved`. An outgoing document with an unreadable `toUserId` is kept: +/// we cannot prove it was resolved, and hiding a request the user may still be +/// waiting on is the worse failure. +fn retain_unresolved( + incoming: &mut Vec<(Identifier, Document)>, + outgoing: &mut Vec<(Identifier, Document)>, + is_resolved: impl Fn(&Identifier) -> bool, +) { + incoming.retain(|(_, doc)| !is_resolved(&doc.owner_id())); + outgoing.retain(|(_, doc)| match recipient_of(doc) { + Some(to) => !is_resolved(&to), + None => true, + }); +} + +/// Verify that `doc` is a contact request `owner` actually sent, and return its +/// recipient. Guards the cancel path against acting on a stale UI row that +/// belongs to a different identity. +fn recipient_of_sent_request( + doc: &Document, + owner: &Identifier, +) -> Result { + if doc.owner_id() != *owner { + return Err(DashPayError::ContactRequestNotSentByYou); + } + recipient_of(doc).ok_or_else(|| DashPayError::InvalidDocument { + reason: "contact request document is missing its toUserId field".to_string(), + }) +} + pub async fn send_contact_request( app_context: &Arc, sdk: &Sdk, @@ -836,3 +887,206 @@ pub async fn reject_contact_request( request_id, )) } + +/// Cancel a contact request this identity sent and that is still pending. +/// +/// DashPay `contactRequest` documents are immutable and cannot be deleted +/// (`documentsMutable: false`, `canBeDeleted: false` in the DashPay contract), +/// so the request cannot be un-sent from Platform. Cancelling therefore does +/// what `reject_contact_request` does for the incoming direction: it broadcasts +/// a `contactInfo` document marking the recipient hidden — a real, persisted +/// state transition — and records the withdrawal in the DET sidecar so the +/// request stops being listed as pending. +/// +/// State is re-verified against Platform before acting: the request must still +/// exist, must have been sent by `identity`, and must not have been answered in +/// the meantime. +pub async fn cancel_contact_request( + app_context: &Arc, + sdk: &Sdk, + identity: QualifiedIdentity, + request_id: Identifier, +) -> Result { + let dashpay_contract = app_context.dashpay_contract.clone(); + let owner_id = identity.identity.id(); + + // Step 1: re-fetch the request rather than trusting the row the user + // clicked — the list may be stale by seconds or by an identity switch. + let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest").map_err(|e| { + DashPayError::QueryCreation { + query_target: "DashPay contactRequest", + source: Box::new(e), + } + })?; + let query_with_id = DocumentQuery::with_document_id(query, &request_id); + + let doc = Document::fetch(sdk, query_with_id) + .await? + .ok_or(TaskError::DocumentNotFound)?; + + // Step 2: verify we sent it, and to whom. + let to_identity_id = recipient_of_sent_request(&doc, &owner_id)?; + + // Step 3: if the recipient already sent one back, the request is no longer + // pending — it is an established contact. Cancelling it would be a lie, so + // report the real state and let the UI refresh into it. + let mut reciprocal_query = + DocumentQuery::new(dashpay_contract, "contactRequest").map_err(|e| { + DashPayError::QueryCreation { + query_target: "DashPay contactRequest", + source: Box::new(e), + } + })?; + reciprocal_query = reciprocal_query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(to_identity_id.to_buffer()), + }) + .with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(owner_id.to_buffer()), + }); + reciprocal_query.limit = 1; + + if !Document::fetch_many(sdk, reciprocal_query) + .await? + .is_empty() + { + return Ok(BackendTaskSuccessResult::DashPayContactAlreadyEstablished( + to_identity_id, + )); + } + + // Step 4: broadcast the withdrawal as a hidden contactInfo document. This is + // the only part of a cancellation that Platform can carry. + super::contact_info::create_or_update_contact_info( + app_context, + sdk, + identity, + to_identity_id, + None, // No nickname + None, // No note + true, // display_hidden = true for a withdrawn request + Vec::new(), // No accepted accounts + ) + .await?; + + // Step 5: mirror the withdrawal into the DET sidecar. `load_contact_requests` + // consults this, so the row stops being listed even though the document + // itself lives on forever. + if let Ok(backend) = app_context.wallet_backend() + && let Err(e) = backend.dashpay_mark_rejected(&owner_id, &to_identity_id) + { + tracing::debug!( + to = %to_identity_id.to_string(Encoding::Base58), + error = ?e, + "DashPay cancellation sidecar write failed; request will still display as pending" + ); + } + + Ok(BackendTaskSuccessResult::DashPayContactRequestCancelled( + request_id, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use dash_sdk::dpp::document::DocumentV0; + + fn id(byte: u8) -> Identifier { + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") + } + + /// Build a `contactRequest`-shaped document. `to` of `None` omits the + /// `toUserId` property entirely, modelling a malformed document. + fn request_doc(owner: Identifier, to: Option) -> Document { + let mut properties = BTreeMap::new(); + if let Some(to) = to { + properties.insert("toUserId".to_string(), Value::Identifier(to.to_buffer())); + } + DppDocument::V0(DocumentV0 { + id: id(99), + owner_id: owner, + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }) + } + + #[test] + fn recipient_of_sent_request_returns_the_recipient() { + let doc = request_doc(id(1), Some(id(2))); + assert_eq!(recipient_of_sent_request(&doc, &id(1)).unwrap(), id(2)); + } + + #[test] + fn cancelling_someone_elses_request_is_rejected() { + // A stale row from a previous identity must never cancel through. + let doc = request_doc(id(1), Some(id(2))); + assert!(matches!( + recipient_of_sent_request(&doc, &id(3)), + Err(DashPayError::ContactRequestNotSentByYou) + )); + } + + #[test] + fn cancelling_a_malformed_request_is_rejected() { + let doc = request_doc(id(1), None); + assert!(matches!( + recipient_of_sent_request(&doc, &id(1)), + Err(DashPayError::InvalidDocument { .. }) + )); + } + + #[test] + fn retain_unresolved_drops_declined_and_cancelled_rows() { + // Incoming from id(1) — declined. Incoming from id(2) — still pending. + let mut incoming = vec![ + (id(10), request_doc(id(1), Some(id(9)))), + (id(11), request_doc(id(2), Some(id(9)))), + ]; + // Outgoing to id(3) — cancelled. Outgoing to id(4) — still pending. + let mut outgoing = vec![ + (id(12), request_doc(id(9), Some(id(3)))), + (id(13), request_doc(id(9), Some(id(4)))), + ]; + + let resolved = [id(1), id(3)]; + retain_unresolved(&mut incoming, &mut outgoing, |c| resolved.contains(c)); + + assert_eq!(incoming.len(), 1, "the declined request must not be listed"); + assert_eq!(incoming[0].0, id(11)); + assert_eq!( + outgoing.len(), + 1, + "the cancelled request must not be listed" + ); + assert_eq!(outgoing[0].0, id(13)); + } + + #[test] + fn retain_unresolved_keeps_rows_with_unreadable_recipients() { + let mut incoming = Vec::new(); + let mut outgoing = vec![(id(12), request_doc(id(9), None))]; + + retain_unresolved(&mut incoming, &mut outgoing, |_| true); + + assert_eq!( + outgoing.len(), + 1, + "a request we cannot attribute must stay visible rather than be silently hidden" + ); + } +} diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs index 8b1de0d51..b78f203bc 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -97,6 +97,14 @@ pub enum DashPayError { #[error("You have already sent a contact request to '{to}'. Please wait for them to respond.")] ContactRequestAlreadySent { to: String }, + /// The request selected for cancellation was sent by a different identity. + /// Almost always a stale list: the user switched identity while the + /// Contacts tab still showed the previous identity's sent requests. + #[error( + "This request was not sent by the identity you are using. Refresh your contacts and try again." + )] + ContactRequestNotSentByYou, + /// Encrypted contact info fields exceed DashPay contract limits. #[error("Contact info is too large to save. Try shortening your nickname or note.")] ContactInfoValidationFailed { errors: Vec }, diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index e138476c6..c21e020f3 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -233,6 +233,7 @@ pub enum BackendTaskSuccessResult { DashPayContactRequestSent(String), // Username or ID of recipient DashPayContactRequestAccepted(Identifier), // Request ID that was accepted DashPayContactRequestRejected(Identifier), // Request ID that was rejected + DashPayContactRequestCancelled(Identifier), // Request ID whose sent request was withdrawn DashPayContactAlreadyEstablished(Identifier), // Contact ID that already exists DashPayContactInfoUpdated(Identifier), // Contact ID whose info was updated DashPayPaymentSent(String, String, u64), // (recipient, address, amount in duffs) diff --git a/src/ui/identity/contacts.rs b/src/ui/identity/contacts.rs index 9bc69346d..4db0a2341 100644 --- a/src/ui/identity/contacts.rs +++ b/src/ui/identity/contacts.rs @@ -5,18 +5,16 @@ //! currently-active identity has no DashPay profile yet. See design-spec §B.4 //! and §B.4.1. //! -//! The tab does **not** introduce any new backend tasks — the populated-state -//! list feeds off the existing [`DashPayTask::LoadContacts`] and -//! [`DashPayTask::LoadContactRequests`] variants. Wire-through of the -//! dispatched results is owned by the hub screen via -//! `hub_screen::display_task_result`, which calls [`ContactsState::record_requests`] -//! to hydrate the [`ContactsState::incoming`] / [`ContactsState::outgoing`] caches. +//! The three sections feed off [`DashPayTask::LoadContacts`] and +//! [`DashPayTask::LoadContactRequests`], dispatched once per tab entry. Results +//! are stored by `hub_screen::display_task_result` into the +//! [`ContactsState`] view-model, which this module only reads. //! -//! The Received and Sent sections render live rows from those caches (T29). The -//! interactive Accept / Decline / Cancel button actions ship in a follow-up -//! task — the rows are currently display-only. +//! Row actions dispatch the DashPay backend tasks directly: Accept and Decline +//! on a received request, Cancel on a sent one, and Pay on an established +//! contact (which opens the existing send-payment screen). -use super::request_card::RequestCard; +use super::request_card::{RequestAction, RequestCard}; use super::social_profile_gate_card::SocialProfileGateCard; use crate::app::AppAction; use crate::backend_task::BackendTask; @@ -24,26 +22,37 @@ use crate::backend_task::dashpay::DashPayTask; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; +use crate::ui::state::contacts_view::{abbreviate_id, contact_label}; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt, Shape}; -use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::platform::{Document, Identifier}; +use dash_sdk::platform::Identifier; use eframe::egui::{CornerRadius, Frame, Margin, RichText, Stroke, Ui}; use std::sync::Arc; +pub use crate::ui::state::contacts_view::{ContactRequestEntry, ContactsState}; + /// Copy constants, kept public so tests and sibling callsites share a single /// source of truth. Complete sentences with no positional assumptions so /// future i18n extraction is one line per string. pub const ADD_BY_USERNAME_LABEL: &str = "Add by username"; pub const SCAN_QR_LABEL: &str = "Scan QR"; pub const SHOW_MY_QR_LABEL: &str = "Show my QR"; +pub const PAY_LABEL: &str = "Pay"; pub const RECEIVED_HEADING: &str = "Received requests"; pub const ACTIVE_HEADING_PREFIX: &str = "Active contacts"; pub const SENT_HEADING: &str = "Sent requests"; pub const NO_RECEIVED_EMPTY: &str = "No pending requests."; pub const NO_ACTIVE_EMPTY: &str = "You have no contacts yet."; +pub const NO_SENT_EMPTY: &str = "No outgoing requests."; +pub const NO_SEARCH_MATCH: &str = "No contact matches your search."; pub const SEARCH_PLACEHOLDER: &str = "Search your contacts"; +/// Sent-section caption. A DashPay contact request cannot be deleted from the +/// network once sent, so the copy states plainly what Cancel really does +/// instead of promising a withdrawal the protocol cannot deliver. +pub const CANCEL_EXPLAINER: &str = "Cancelling a request hides it and tells the other person you are no longer waiting. The \ + original request stays on the network."; + /// Every clickable affordance on the Contacts tab header + populated shell. /// Mirrors the home-tab `HomeButton` dispatcher pattern so the dead-button /// unit test can enumerate every variant and assert each one maps to a real @@ -84,7 +93,7 @@ pub enum ContactsScreenKind { } /// Pure dispatcher. Every variant MUST produce a non-dead result — the -/// `every_contacts_button_produces_live_action` test enforces this. +/// `every_contacts_button_maps_to_a_live_action` test enforces this. pub fn contacts_button_kind(button: ContactsButton) -> ContactsButtonKind { use ContactsButtonKind::*; use ContactsScreenKind::*; @@ -101,110 +110,45 @@ pub fn contacts_button_kind(button: ContactsButton) -> ContactsButtonKind { } } -/// A single cached contact-request entry, derived from a raw -/// `DashPayContactRequests` result document. -#[derive(Debug, Clone)] -pub struct ContactRequestEntry { - /// Base58 identity ID of the counterpart: the sender for incoming - /// requests, the recipient for outgoing ones. Used as the display label - /// until the profile-fetch chain surfaces a display name. - pub counterpart_id: String, - /// Base58 request document ID — echoed back into `RequestCardResponse::id` - /// so Accept/Decline/Cancel handlers can route to the right task. - pub request_id: String, - /// Human-relative timestamp (e.g. `"2 minutes ago"`), pre-formatted from - /// the document's `created_at`. `None` when the document has no timestamp. - pub relative_time: Option, -} - -/// Per-tab-entry state owned by the hub. Holds the load guard flag and the -/// cached contact-request lists populated by `hub_screen::display_task_result`. -/// -/// The flag is reset by the hub when the user leaves the tab (via -/// [`ContactsState::reset`]) or via an explicit refresh affordance. -#[derive(Debug, Default, Clone)] -pub struct ContactsState { - /// Set to `true` after the first paint of the populated shell triggers - /// the backend tasks. Guards all subsequent frames from re-dispatching. - pub(super) load_requested: bool, - /// Cached incoming requests (received by the active identity). Populated - /// from `BackendTaskSuccessResult::DashPayContactRequests` via - /// `record_requests`. Cleared on `reset()`. - pub incoming: Vec, - /// Cached outgoing requests (sent by the active identity). Same lifecycle. - pub outgoing: Vec, -} - -impl ContactsState { - /// Clear the load guard and cached lists so the next paint re-issues the - /// load. Call this from `refresh()` / `refresh_on_arrival()` on the hub. - pub fn reset(&mut self) { - self.load_requested = false; - self.incoming.clear(); - self.outgoing.clear(); - } - - /// Populate the incoming/outgoing caches from a raw - /// `DashPayContactRequests` backend result. Called by - /// `hub_screen::display_task_result` (T29). - /// - /// Incoming sender = `doc.owner_id()`; outgoing recipient = - /// `doc.properties()["toUserId"]`. Display names are identity IDs until a - /// profile-fetch integration wave lands. - pub fn record_requests( - &mut self, - incoming: Vec<(Identifier, Document)>, - outgoing: Vec<(Identifier, Document)>, - ) { - self.incoming = incoming - .into_iter() - .map(|(req_id, doc)| { - let ts = doc.created_at().or_else(|| doc.updated_at()).unwrap_or(0); - ContactRequestEntry { - counterpart_id: doc.owner_id().to_string(Encoding::Base58), - request_id: req_id.to_string(Encoding::Base58), - relative_time: crate::ui::dashpay::format_relative_time(ts), - } - }) - .collect(); - - self.outgoing = outgoing - .into_iter() - .map(|(req_id, doc)| { - let to_id = doc - .properties() - .get("toUserId") - .and_then(|v| v.to_identifier().ok()) - .unwrap_or_default() - .to_string(Encoding::Base58); - let ts = doc.created_at().or_else(|| doc.updated_at()).unwrap_or(0); - ContactRequestEntry { - counterpart_id: to_id, - request_id: req_id.to_string(Encoding::Base58), - relative_time: crate::ui::dashpay::format_relative_time(ts), - } - }) - .collect(); +/// Backend task for a request-card action. Accept and Decline act on a received +/// request; Cancel withdraws a sent one. +pub fn request_task( + action: RequestAction, + identity: QualifiedIdentity, + request_id: Identifier, +) -> DashPayTask { + match action { + RequestAction::Accepted => DashPayTask::AcceptContactRequest { + identity, + request_id, + }, + RequestAction::Declined => DashPayTask::RejectContactRequest { + identity, + request_id, + }, + RequestAction::Cancelled => DashPayTask::CancelContactRequest { + identity, + request_id, + }, } } /// Public entry point invoked by `hub_screen` when the Contacts tab is active. /// -/// Resolves the "current" identity as the first locally-loaded identity on -/// the active network (a pragmatic default until T7's identity picker lands). -/// When no identity is loaded, or the active identity has no DashPay profile, -/// the gated state is rendered. +/// Resolves the "current" identity as the app-scoped active identity. When no +/// identity is loaded, or the active identity has no DashPay profile, the gated +/// state is rendered. /// -/// The caller owns a [`ContactsState`] so the populated-shell only dispatches -/// its backend task once per tab entry — not once per paint. +/// The caller owns a [`ContactsState`] so the populated shell only dispatches +/// its backend tasks once per tab entry — not once per paint. pub fn render( ui: &mut Ui, app_context: &Arc, - state_guard: &mut ContactsState, + state: &mut ContactsState, profiles: &mut super::profile_cache::ProfileCache, ) -> AppAction { - let state = ContactsTabState::resolve(app_context, profiles); - render_state(ui, app_context, &state, state_guard) + let tab_state = ContactsTabState::resolve(app_context, profiles); + render_state(ui, app_context, &tab_state, state) } /// Resolved rendering mode for the Contacts tab. @@ -256,13 +200,13 @@ impl ContactsTabState { fn render_state( ui: &mut Ui, app_context: &Arc, - state: &ContactsTabState, - state_guard: &mut ContactsState, + tab_state: &ContactsTabState, + state: &mut ContactsState, ) -> AppAction { - match state { + match tab_state { ContactsTabState::Gated { handle } => render_gated(ui, handle.as_deref()), ContactsTabState::Populated { identity } => { - render_populated(ui, app_context, identity, state_guard) + render_populated(ui, app_context, identity, state) } } } @@ -301,148 +245,214 @@ pub fn render_gated(ui: &mut Ui, handle: Option<&str>) -> AppAction { AppAction::None } -/// Populated-state shell — three sections and a dispatch of -/// [`DashPayTask::LoadContacts`] + [`DashPayTask::LoadContactRequests`] on -/// first paint. Results land in `ContactsState` via -/// `hub_screen::display_task_result` (T29). +/// Populated-state shell — three sections, each rendering live rows, plus a +/// one-shot dispatch of [`DashPayTask::LoadContacts`] + +/// [`DashPayTask::LoadContactRequests`]. fn render_populated( ui: &mut Ui, app_context: &Arc, identity: &QualifiedIdentity, - state_guard: &mut ContactsState, + state: &mut ContactsState, ) -> AppAction { let dark_mode = ui.ctx().global_style().visuals.dark_mode; let mut action = AppAction::None; - // Snapshot the lists so the closures below can read them without holding - // a borrow on `state_guard` while we also mutate it in the dispatch block. - let incoming = state_guard.incoming.clone(); - let outgoing = state_guard.outgoing.clone(); - action |= header_row(ui, app_context, dark_mode); - // --- Received requests section --------------------------------------- + action |= received_section(ui, identity, state, dark_mode); + action |= active_section(ui, app_context, identity, state, dark_mode); + action |= sent_section(ui, identity, state, dark_mode); + + // Fire LoadContacts + LoadContactRequests once per tab entry. The hub + // resets the guard in `refresh_on_arrival()` so a tab switch or explicit + // refresh triggers another load. Both tasks dispatch together so all three + // sections hydrate in a single round-trip. + if state.claim_load() { + action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadContacts { + identity: identity.clone(), + }, + ))); + action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + DashPayTask::LoadContactRequests { + identity: identity.clone(), + }, + ))); + } + + action +} + +/// Received requests — one [`RequestCard`] per incoming request, with Accept +/// and Decline wired to their backend tasks. +fn received_section( + ui: &mut Ui, + identity: &QualifiedIdentity, + state: &ContactsState, + dark_mode: bool, +) -> AppAction { + let mut action = AppAction::None; ui.add_space(12.0); - let heading_recv = if incoming.is_empty() { + + let incoming = state.incoming(); + let heading = if incoming.is_empty() { RECEIVED_HEADING.to_string() } else { format!("{RECEIVED_HEADING} · {}", incoming.len()) }; - section_card(ui, dark_mode, &heading_recv, |ui| { + + section_card(ui, dark_mode, &heading, |ui| { if incoming.is_empty() { ui.label(RichText::new(NO_RECEIVED_EMPTY).color(DashColors::text_secondary(dark_mode))); - } else { - for entry in &incoming { - let display = abbreviate_id(&entry.counterpart_id); - let card = RequestCard::received( - &display, - &entry.counterpart_id, - entry.relative_time.as_deref().unwrap_or(""), - ) - .with_id(&entry.request_id); - card.show(ui); - ui.add_space(4.0); - // TODO(identity-hub/T29): on Accept/Decline dispatch - // DashPayTask::AcceptContactRequest / RejectContactRequest - // using `resp.id` as the request identifier. Backend variants - // already exist; wiring is additive here. + return; + } + for entry in incoming { + let handle = entry.counterpart_id.to_string(Encoding::Base58); + let response = RequestCard::received( + abbreviate_id(&handle), + &handle, + entry.relative_time.as_deref().unwrap_or(""), + ) + .show(ui); + ui.add_space(4.0); + + if let Some(request_action) = response.action() { + action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new(request_task( + request_action, + identity.clone(), + entry.request_id, + )))); } } }); - // --- Active contacts section ---------------------------------------- + action +} + +/// Active contacts — searchable list of established contacts, each row offering +/// a Pay affordance that opens the existing send-payment screen. +fn active_section( + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + state: &mut ContactsState, + dark_mode: bool, +) -> AppAction { + let mut action = AppAction::None; ui.add_space(12.0); - let mut active_add_action = AppAction::None; - section_card( - ui, - dark_mode, - &format!("{ACTIVE_HEADING_PREFIX} · 0"), - |ui| { - // Placeholder search input so the populated shell matches the - // wireframe layout even before the real list is wired. - let mut search = String::new(); + + let heading = format!("{ACTIVE_HEADING_PREFIX} · {}", state.contacts_len()); + let has_contacts = state.contacts_len() > 0; + + section_card(ui, dark_mode, &heading, |ui| { + // The search box only earns its place once there is something to search. + if has_contacts { ui.add( - eframe::egui::TextEdit::singleline(&mut search) + eframe::egui::TextEdit::singleline(state.search_mut()) .hint_text(SEARCH_PLACEHOLDER) .desired_width(f32::INFINITY), ); ui.add_space(8.0); + } + + let matches = state.filtered_contacts(); + if !has_contacts { ui.label(RichText::new(NO_ACTIVE_EMPTY).color(DashColors::text_secondary(dark_mode))); - ui.add_space(8.0); - let add_resp = ui - .add(ComponentStyles::primary_button(ADD_BY_USERNAME_LABEL)) - .clickable_tooltip( - "Find someone by their Dash username or identity ID and add them as a \ - contact.", - ); - if add_resp.clicked() { - active_add_action = - resolve_contacts_button(ContactsButton::ActiveAddByUsername, app_context); + } else if matches.is_empty() { + ui.label(RichText::new(NO_SEARCH_MATCH).color(DashColors::text_secondary(dark_mode))); + } else { + for contact in matches { + ui.horizontal(|ui| { + ui.label( + RichText::new(contact_label(contact)) + .strong() + .color(DashColors::text_primary(dark_mode)), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + let pay = ui + .add(ComponentStyles::secondary_button(PAY_LABEL, dark_mode)) + .clickable_tooltip("Send Dash to this contact."); + if pay.clicked() { + action = AppAction::AddScreen( + ScreenType::DashPaySendPayment( + identity.clone(), + contact.identity_id, + ) + .create_screen(app_context), + ); + } + }, + ); + }); + ui.add_space(4.0); } - }, - ); - action |= active_add_action; + } - // --- Sent requests section ------------------------------------------ - // - // Per design §B.4 the section collapses when empty. Always rendered here - // so the heading is visible while data is loading. + ui.add_space(8.0); + let add = ui + .add(ComponentStyles::primary_button(ADD_BY_USERNAME_LABEL)) + .clickable_tooltip( + "Find someone by their Dash username or identity ID and add them as a contact.", + ); + if add.clicked() { + action = resolve_contacts_button(ContactsButton::ActiveAddByUsername, app_context); + } + }); + + action +} + +/// Sent requests — one [`RequestCard`] per pending outgoing request, with +/// Cancel wired to [`DashPayTask::CancelContactRequest`]. +fn sent_section( + ui: &mut Ui, + identity: &QualifiedIdentity, + state: &ContactsState, + dark_mode: bool, +) -> AppAction { + let mut action = AppAction::None; ui.add_space(12.0); - let heading_sent = if outgoing.is_empty() { + + let outgoing = state.outgoing(); + let heading = if outgoing.is_empty() { SENT_HEADING.to_string() } else { format!("{SENT_HEADING} · {}", outgoing.len()) }; - section_card(ui, dark_mode, &heading_sent, |ui| { + + section_card(ui, dark_mode, &heading, |ui| { if outgoing.is_empty() { - ui.label( - RichText::new("No outgoing requests.").color(DashColors::text_secondary(dark_mode)), - ); - } else { - for entry in &outgoing { - let display = abbreviate_id(&entry.counterpart_id); - let card = - RequestCard::sent(&display, &entry.counterpart_id).with_id(&entry.request_id); - card.show(ui); - ui.add_space(4.0); - // TODO(identity-hub/T29): on Cancel dispatch - // DashPayTask::CancelContactRequest (variant not yet present — - // defer until the parallel wallet-refactor wave lands). + ui.label(RichText::new(NO_SENT_EMPTY).color(DashColors::text_secondary(dark_mode))); + return; + } + + ui.label( + RichText::new(CANCEL_EXPLAINER) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + for entry in outgoing { + let handle = entry.counterpart_id.to_string(Encoding::Base58); + let response = RequestCard::sent(abbreviate_id(&handle), &handle).show(ui); + ui.add_space(4.0); + + if let Some(request_action) = response.action() { + action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new(request_task( + request_action, + identity.clone(), + entry.request_id, + )))); } } }); - // Fire LoadContacts + LoadContactRequests once per tab-entry. The guard - // prevents re-dispatch every frame. The hub resets it in - // `refresh_on_arrival()` so a tab switch or explicit refresh triggers - // another load. Both tasks dispatch together so all three sections can - // hydrate in a single round-trip (T29). - if !state_guard.load_requested { - state_guard.load_requested = true; - action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - DashPayTask::LoadContacts { - identity: identity.clone(), - }, - ))); - action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - DashPayTask::LoadContactRequests { - identity: identity.clone(), - }, - ))); - } - action } -/// Shorten a Base58 identity ID for display: first 8 chars + "…". -fn abbreviate_id(id: &str) -> String { - if id.len() <= 10 { - id.to_string() - } else { - format!("{}…", &id[..8]) - } -} - /// Header row: title on the left, three action buttons right-aligned. /// /// Returns any `AppAction` generated by clicks on the three header buttons @@ -565,6 +575,37 @@ fn has_social_profile( #[cfg(test)] mod tests { use super::*; + use crate::model::qualified_identity::{IdentityStatus, IdentityType}; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::Identity; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use std::collections::BTreeMap; + + fn id(byte: u8) -> Identifier { + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") + } + + fn qualified_identity(identifier: Identifier) -> QualifiedIdentity { + let identity = Identity::create_basic_identity(identifier, PlatformVersion::latest()) + .expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: Default::default(), + status: IdentityStatus::Active, + network: Network::Testnet, + } + } #[test] fn gated_state_variant_preserves_handle() { @@ -579,24 +620,41 @@ mod tests { } } - #[test] - fn gated_state_variant_accepts_absent_handle() { - let s = ContactsTabState::Gated { handle: None }; - matches!(s, ContactsTabState::Gated { handle: None }); - } - #[test] fn populated_heading_format_matches_design() { // Design-spec §B.4: active-contacts header reads `Active contacts · {n}`. + let mut state = ContactsState::default(); assert_eq!( - format!("{ACTIVE_HEADING_PREFIX} · 0"), + format!("{ACTIVE_HEADING_PREFIX} · {}", state.contacts_len()), "Active contacts · 0" ); + state.record_contacts(vec![crate::backend_task::dashpay::ContactData { + identity_id: id(5), + nickname: Some("Bao".into()), + note: None, + is_hidden: false, + account_reference: 0, + username: None, + display_name: None, + avatar_url: None, + bio: None, + }]); + assert_eq!( + format!("{ACTIVE_HEADING_PREFIX} · {}", state.contacts_len()), + "Active contacts · 1", + "the heading count must reflect the loaded contacts, not a hardcoded zero" + ); } #[test] fn copy_constants_are_complete_sentences() { - for line in [NO_RECEIVED_EMPTY, NO_ACTIVE_EMPTY] { + for line in [ + NO_RECEIVED_EMPTY, + NO_ACTIVE_EMPTY, + NO_SENT_EMPTY, + NO_SEARCH_MATCH, + CANCEL_EXPLAINER, + ] { assert!( line.ends_with('.'), "empty-state copy '{line}' must end with a period" @@ -609,11 +667,8 @@ mod tests { } // --------------------------------------------------------------- - // Dead-button regression tests — same invariant as home.rs: - // every interactive button MUST produce a live result from the - // pure dispatcher. The T8-Wave-2 regression was that the header - // buttons were rendered without any click handling at all; this - // suite pins the expected mapping. + // Dead-button regression tests — every interactive control MUST + // produce a live result from a pure dispatcher. // --------------------------------------------------------------- const ALL_CONTACTS_BUTTONS: &[ContactsButton] = &[ @@ -624,26 +679,13 @@ mod tests { ContactsButton::GateSetUpProfile, ]; - #[test] - fn contacts_all_buttons_list_is_exhaustive() { - for button in ALL_CONTACTS_BUTTONS { - let _: () = match *button { - ContactsButton::HeaderAddByUsername => (), - ContactsButton::HeaderScanQr => (), - ContactsButton::HeaderShowMyQr => (), - ContactsButton::ActiveAddByUsername => (), - ContactsButton::GateSetUpProfile => (), - }; - } - } - #[test] fn every_contacts_button_maps_to_a_live_action() { for button in ALL_CONTACTS_BUTTONS { let kind = contacts_button_kind(*button); - // The dispatcher only produces two variants; both are live — - // `OpenScreen` resolves to `AppAction::AddScreen(...)` and - // `SwitchHubTab` to `AppAction::SwitchIdentityHubTab(...)`. + // Both variants are live — `OpenScreen` resolves to + // `AppAction::AddScreen(...)` and `SwitchHubTab` to + // `AppAction::SwitchIdentityHubTab(...)`. match kind { ContactsButtonKind::OpenScreen(_) | ContactsButtonKind::SwitchHubTab(_) => {} } @@ -682,86 +724,67 @@ mod tests { ); } - /// T28 regression guard — `ContactsState::reset()` must clear the load guard - /// so the next render re-dispatches `LoadContacts`. This is the low-level - /// assertion beneath the `change_context` → `refresh` → `contacts_state.reset()` - /// chain that prevents stale contacts from a previous network/identity being - /// served after a context switch. - #[test] - fn t28_reset_clears_load_guard() { - let mut state = ContactsState { - load_requested: true, - ..Default::default() - }; - state.reset(); - - assert!( - !state.load_requested, - "reset() must clear load_requested so the next render re-fires LoadContacts" - ); - } - // --------------------------------------------------------------- - // ContactsState cache wiring + // Request-row actions — the dead-button regression these rows had: + // Accept/Decline were TODO stubs and Cancel had no task at all. // --------------------------------------------------------------- - /// T28 regression guard (extended): reset() must clear the load guard AND - /// the request caches so a refresh or network-switch re-fires the load - /// and the Received/Sent sections don't show stale data. #[test] - fn t28_reset_clears_load_guard_and_caches() { - let mut state = ContactsState { - load_requested: true, - incoming: vec![ContactRequestEntry { - counterpart_id: "AAA".into(), - request_id: "RRR".into(), - relative_time: None, - }], - outgoing: vec![ContactRequestEntry { - counterpart_id: "BBB".into(), - request_id: "SSS".into(), - relative_time: None, - }], - }; - state.reset(); - assert!( - !state.load_requested, - "reset() must clear the load guard (T28)" - ); - assert!( - state.incoming.is_empty(), - "reset() must clear incoming cache" - ); + fn accept_maps_to_the_accept_task_for_that_request() { + let identity = qualified_identity(id(1)); + let task = request_task(RequestAction::Accepted, identity, id(2)); + match task { + DashPayTask::AcceptContactRequest { + identity, + request_id, + } => { + assert_eq!(identity.identity.id(), id(1)); + assert_eq!( + request_id, + id(2), + "the clicked row's request must be acted on" + ); + } + other => panic!("Accept must dispatch AcceptContactRequest, got {other:?}"), + } + } + + #[test] + fn decline_maps_to_the_reject_task() { + let task = request_task(RequestAction::Declined, qualified_identity(id(1)), id(2)); assert!( - state.outgoing.is_empty(), - "reset() must clear outgoing cache" + matches!(task, DashPayTask::RejectContactRequest { request_id, .. } if request_id == id(2)), + "Decline must dispatch RejectContactRequest for the clicked request" ); } - /// abbreviate_id() trims long Base58 IDs to 8 chars + "…". #[test] - fn abbreviate_id_shortens_long_ids() { - let long = "AbCdEfGhIjKlMnOpQrStUv"; - assert_eq!(abbreviate_id(long), "AbCdEfGh…"); - let short = "AbCdEfGh"; - assert_eq!(abbreviate_id(short), "AbCdEfGh"); - let empty = ""; - assert_eq!(abbreviate_id(empty), ""); + fn cancel_maps_to_the_cancel_task() { + let task = request_task(RequestAction::Cancelled, qualified_identity(id(1)), id(2)); + assert!( + matches!(task, DashPayTask::CancelContactRequest { request_id, .. } if request_id == id(2)), + "Cancel must dispatch CancelContactRequest for the clicked request" + ); } - /// Received and sent section headings count the entries. #[test] - fn section_headings_include_count_when_populated() { - let mut state = ContactsState::default(); - state.incoming.push(ContactRequestEntry { - counterpart_id: "AAA".into(), - request_id: "RRR".into(), - relative_time: Some("1 minute ago".into()), - }); - assert_eq!( - format!("{RECEIVED_HEADING} · {}", state.incoming.len()), - "Received requests · 1" - ); - assert_eq!(RECEIVED_HEADING, "Received requests"); + fn every_request_action_maps_to_a_distinct_task() { + let tasks: Vec = [ + RequestAction::Accepted, + RequestAction::Declined, + RequestAction::Cancelled, + ] + .into_iter() + .map(|a| request_task(a, qualified_identity(id(1)), id(2))) + .collect(); + + for (i, a) in tasks.iter().enumerate() { + for b in tasks.iter().skip(i + 1) { + assert_ne!( + a, b, + "each request action must dispatch its own task, never a shared one" + ); + } + } } } diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 2f5f58113..51c98a9f5 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -125,6 +125,19 @@ impl IdentityHubScreen { self.selected_tab = tab; } + /// Retire a request row the backend just resolved (accepted, declined, or + /// cancelled), confirm it to the user, and re-arm the Contacts load so the + /// authoritative lists replace the local edit. + fn resolve_request(&mut self, request_id: &Identifier, confirmation: &str) { + self.contacts_state.remove_request(request_id); + self.contacts_state.invalidate(); + MessageBanner::set_global( + self.app_context.egui_ctx(), + confirmation, + MessageType::Success, + ); + } + /// Apply a breadcrumb-switcher effect: wallet / identity switches mutate the /// app-scoped selection and reset identity-scoped caches; add-flows route to /// the existing screens. @@ -349,6 +362,33 @@ impl ScreenLike for IdentityHubScreen { self.contacts_state .record_requests(incoming.clone(), outgoing.clone()); } + // The established-contact list behind the Contacts tab's active + // section and its search box. + BackendTaskSuccessResult::DashPayContactsWithInfo(contacts) => { + self.contacts_state.record_contacts(contacts.clone()); + } + // A resolved request: drop the row now so the list reflects the + // action immediately, then re-arm the load so the authoritative + // lists (including the new contact, on accept) replace it. + BackendTaskSuccessResult::DashPayContactRequestAccepted(request_id) => { + self.resolve_request(request_id, "Contact request accepted."); + } + BackendTaskSuccessResult::DashPayContactRequestRejected(request_id) => { + self.resolve_request(request_id, "Contact request declined."); + } + BackendTaskSuccessResult::DashPayContactRequestCancelled(request_id) => { + self.resolve_request(request_id, "Contact request cancelled."); + } + // The counterpart answered while the row was on screen. Nothing to + // withdraw or accept — reload into the truth. + BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { + self.contacts_state.invalidate(); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "You are already contacts with this person.", + MessageType::Info, + ); + } _ => {} } } diff --git a/src/ui/identity/settings.rs b/src/ui/identity/settings.rs index 4064b0f9f..b58c67693 100644 --- a/src/ui/identity/settings.rs +++ b/src/ui/identity/settings.rs @@ -30,9 +30,11 @@ use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::qualified_identity::{IdentityType, QualifiedIdentity}; +use crate::ui::MessageType; use crate::ui::ScreenType; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::message_banner::MessageBanner; use crate::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -60,7 +62,18 @@ const TIP_ADD_KEY: &str = const TIP_REFRESH: &str = "Fetch the latest state of this identity from the network."; const TIP_UNLOAD: &str = "Remove this identity from this device. It remains on Dash Platform — you can load it \ again later."; +const TIP_SAVE_ALIAS: &str = "Save this name on this device."; const TIP_ID_COPY: &str = "Copy the full identity ID to your clipboard."; + +// Local-alias copy. The alias never leaves the device, so the copy leads with +// that: users must not think they are publishing a name to the network. +const ALIAS_HEADING: &str = "Name on this device"; +const ALIAS_EXPLAINER: &str = + "Only you see this name. It is stored on this device and never published to Dash Platform."; +const ALIAS_HINT: &str = "For example: My main identity"; +const ALIAS_SAVED: &str = "Name saved on this device."; +const ALIAS_SAVE_FAILED: &str = + "This name could not be saved on your device. Try again in a moment."; const TIP_PROTX_COPY: &str = "Copy the masternode ID to your clipboard."; const TIP_BADGE_USER: &str = "A regular identity used for payments, DPNS, and DashPay."; const TIP_BADGE_MASTERNODE: &str = @@ -100,6 +113,11 @@ pub struct SettingsTab { edit_display_name: String, edit_bio: String, edit_avatar_url: String, + /// Editable local alias — the device-only name for this identity. Loaded + /// from `QualifiedIdentity::alias` on identity change; never published. + edit_alias: String, + /// Last-saved alias, for dirty tracking. Committed on a successful write. + original_alias: String, /// Copy of the originals for `has_changes` comparison. Updated only /// after a CONFIRMED backend success via `on_profile_saved()`. original_display_name: String, @@ -443,6 +461,10 @@ impl SettingsTab { ui.add_space(12.0); + action |= self.render_local_alias(ui, app_context, identity); + + ui.add_space(12.0); + // Aliases block. Each secondary DPNS name appears with Make-primary + // Remove actions; both are GATED because the backend variants do not // exist yet. @@ -492,6 +514,75 @@ impl SettingsTab { action } + /// Local alias block — the device-only name for this identity, and the name + /// the hub's breadcrumb and identity pills prefer over the DPNS handle. + /// + /// The alias is local metadata, not platform state: it is written straight + /// through the `AppContext` wrapper (the same call the DPNS and legacy + /// identity screens use), so there is no state transition and no fee. On a + /// successful write the in-memory identity is updated too, so the pills + /// pick the new name up on the next frame. + fn render_local_alias( + &mut self, + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + ) -> AppAction { + let dark_mode = ui.ctx().global_style().visuals.dark_mode; + + section_heading(ui, ALIAS_HEADING, dark_mode); + ui.label( + RichText::new(ALIAS_EXPLAINER) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(4.0); + + ui.add( + TextEdit::singleline(&mut self.edit_alias) + .hint_text(ALIAS_HINT) + .desired_width(f32::INFINITY), + ); + ui.add_space(6.0); + + let dirty = self.has_alias_changes(); + let save = ComponentStyles::add_primary_button_enabled(ui, dirty, "Save name"); + let save = if dirty { + save.clickable_tooltip(TIP_SAVE_ALIAS) + } else { + save.disabled_tooltip(TIP_SAVE_NO_CHANGES) + }; + + if save.clicked() && dirty { + let new_alias = string_if_set(&self.edit_alias); + match app_context.set_identity_alias(&identity.identity.id(), new_alias.as_deref()) { + Ok(()) => { + // Commit the baseline and mirror onto the cached identity so + // this tab (and the pills reading it) show the saved name + // without waiting for a reload. + self.original_alias = new_alias.clone().unwrap_or_default(); + self.edit_alias = self.original_alias.clone(); + if let Some(selected) = self.selected_identity.as_mut() { + selected.alias = new_alias; + } + MessageBanner::set_global(ui.ctx(), ALIAS_SAVED, MessageType::Success); + } + Err(e) => { + MessageBanner::set_global(ui.ctx(), ALIAS_SAVE_FAILED, MessageType::Error) + .with_details(&e); + } + } + } + + AppAction::None + } + + /// Whether the alias field differs from the last-saved value, comparing the + /// stored (trimmed) form so trailing whitespace alone never enables Save. + fn has_alias_changes(&self) -> bool { + string_if_set(&self.edit_alias).unwrap_or_default() != self.original_alias + } + fn render_advanced( &mut self, ui: &mut Ui, @@ -684,6 +775,13 @@ impl SettingsTab { }; if changed { + // The local alias lives on the identity record itself, so it is + // available immediately — no async profile round-trip needed. + self.edit_alias = incoming + .as_ref() + .and_then(|qi| qi.alias.clone()) + .unwrap_or_default(); + self.original_alias = self.edit_alias.clone(); self.selected_identity = incoming; self.profile_loaded = false; // Clear the editor to a clean slate; fields repopulate once the @@ -930,6 +1028,45 @@ mod tests { assert_eq!(string_if_set(" alex "), Some("alex".to_string())); } + #[test] + fn alias_save_is_enabled_only_by_a_real_change() { + let mut tab = SettingsTab::new(); + assert!(!tab.has_alias_changes(), "an untouched alias is not dirty"); + + tab.edit_alias = "My main identity".into(); + assert!(tab.has_alias_changes(), "a new alias must enable Save"); + + tab.original_alias = "My main identity".into(); + assert!(!tab.has_alias_changes(), "a saved alias is no longer dirty"); + } + + #[test] + fn alias_whitespace_alone_is_not_a_change() { + let mut tab = SettingsTab::new(); + tab.original_alias = "Bao".into(); + tab.edit_alias = " Bao ".into(); + assert!( + !tab.has_alias_changes(), + "padding an unchanged alias with spaces must not enable Save" + ); + } + + #[test] + fn clearing_the_alias_is_a_change_that_stores_none() { + let mut tab = SettingsTab::new(); + tab.original_alias = "Bao".into(); + tab.edit_alias = " ".into(); + assert!( + tab.has_alias_changes(), + "emptying a set alias must enable Save so the user can remove it" + ); + assert_eq!( + string_if_set(&tab.edit_alias), + None, + "an emptied alias must be stored as None, not as an empty string" + ); + } + #[test] fn identity_type_badge_covers_all_variants() { for ty in [ diff --git a/src/ui/state/contacts_view.rs b/src/ui/state/contacts_view.rs new file mode 100644 index 000000000..e8bd1335c --- /dev/null +++ b/src/ui/state/contacts_view.rs @@ -0,0 +1,419 @@ +//! Contacts-tab view state for the Identities hub. +//! +//! Owns the one-shot load guard, the cached contact-request lists, the active +//! contact list, and the search query that filters it. Renders nothing — the +//! renderer lives in [`crate::ui::identity::contacts`], which reads this state +//! and paints it. Placement follows the DET module policy: non-widget UI state +//! belongs in `ui/state/`. + +use crate::backend_task::dashpay::ContactData; +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::platform::{Document, Identifier}; + +/// A single cached contact-request entry, derived from a raw +/// `DashPayContactRequests` result document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ContactRequestEntry { + /// Identity of the counterpart: the sender for incoming requests, the + /// recipient for outgoing ones. + pub counterpart_id: Identifier, + /// The request document's own ID — routed straight into the Accept / + /// Decline / Cancel backend tasks, so no Base58 round-trip is needed. + pub request_id: Identifier, + /// Human-relative timestamp (e.g. `"2 minutes ago"`), pre-formatted from + /// the document's `created_at`. `None` when the document has no timestamp. + pub relative_time: Option, +} + +/// Contacts-tab state owned by the hub screen. +/// +/// The load guard debounces the backend fetch to one dispatch per tab entry; +/// the hub clears it via [`ContactsState::reset`] on refresh, tab switch, and +/// identity/network change. +#[derive(Debug, Default, Clone)] +pub struct ContactsState { + /// `true` once the populated shell has dispatched its loads for this tab + /// entry. Guards every subsequent frame from re-dispatching. + load_requested: bool, + /// Incoming requests awaiting this identity's response. + incoming: Vec, + /// Requests this identity has sent and that are still pending. + outgoing: Vec, + /// Established contacts, from `DashPayTask::LoadContacts`. + contacts: Vec, + /// Live search query bound to the Contacts search box. + search: String, +} + +impl ContactsState { + /// Claim the one-shot load slot. Returns `true` exactly once per tab entry + /// — the caller dispatches the backend loads on `true` and skips otherwise. + pub fn claim_load(&mut self) -> bool { + if self.load_requested { + return false; + } + self.load_requested = true; + true + } + + /// Clear the load guard, cached lists, and search query so the next paint + /// re-issues the load. Called on refresh, tab switch, and identity change. + pub fn reset(&mut self) { + self.load_requested = false; + self.incoming.clear(); + self.outgoing.clear(); + self.contacts.clear(); + self.search.clear(); + } + + /// Re-arm the load without clearing what is already on screen. Used after a + /// request is resolved: the authoritative lists are re-fetched while the + /// user keeps seeing (and searching) the contacts they already had. + pub fn invalidate(&mut self) { + self.load_requested = false; + } + + /// Pending requests received by this identity. + pub fn incoming(&self) -> &[ContactRequestEntry] { + &self.incoming + } + + /// Pending requests sent by this identity. + pub fn outgoing(&self) -> &[ContactRequestEntry] { + &self.outgoing + } + + /// Mutable handle on the search query, for binding to the search `TextEdit`. + pub fn search_mut(&mut self) -> &mut String { + &mut self.search + } + + /// Populate the incoming/outgoing caches from a raw `DashPayContactRequests` + /// backend result. + /// + /// Incoming sender = `doc.owner_id()`; outgoing recipient = + /// `doc.properties()["toUserId"]`. An outgoing document whose `toUserId` is + /// unreadable is dropped rather than shown against a default identity — a + /// row the user cannot act on correctly is worse than no row. + pub fn record_requests( + &mut self, + incoming: Vec<(Identifier, Document)>, + outgoing: Vec<(Identifier, Document)>, + ) { + self.incoming = incoming + .into_iter() + .map(|(request_id, doc)| ContactRequestEntry { + counterpart_id: doc.owner_id(), + request_id, + relative_time: relative_time(&doc), + }) + .collect(); + + self.outgoing = outgoing + .into_iter() + .filter_map(|(request_id, doc)| { + let counterpart_id = doc + .properties() + .get("toUserId") + .and_then(|v| v.to_identifier().ok())?; + Some(ContactRequestEntry { + counterpart_id, + request_id, + relative_time: relative_time(&doc), + }) + }) + .collect(); + } + + /// Store the established-contact list from `DashPayTask::LoadContacts`. + /// Contacts the user has hidden (including ones whose request they declined + /// or cancelled) are dropped — "hidden" is exactly the promise that they do + /// not appear in the list. + pub fn record_contacts(&mut self, contacts: Vec) { + self.contacts = contacts.into_iter().filter(|c| !c.is_hidden).collect(); + } + + /// Number of established contacts, before search filtering. Drives the + /// `Active contacts · {count}` heading. + pub fn contacts_len(&self) -> usize { + self.contacts.len() + } + + /// Contacts matching the current search query, in list order. An empty or + /// whitespace-only query matches every contact. + pub fn filtered_contacts(&self) -> Vec<&ContactData> { + let needle = self.search.trim().to_lowercase(); + self.contacts + .iter() + .filter(|c| matches_search(c, &needle)) + .collect() + } + + /// Drop a resolved request (accepted, declined, or cancelled) from both + /// lists so the row leaves the UI immediately, without waiting for the + /// authoritative reload to land. + pub fn remove_request(&mut self, request_id: &Identifier) { + self.incoming.retain(|e| e.request_id != *request_id); + self.outgoing.retain(|e| e.request_id != *request_id); + } +} + +/// Case-insensitive substring match over every handle a user might type: +/// nickname, display name, DPNS username, and the Base58 identity ID. An empty +/// needle matches everything. +fn matches_search(contact: &ContactData, needle_lowercase: &str) -> bool { + if needle_lowercase.is_empty() { + return true; + } + let haystacks = [ + contact.nickname.as_deref(), + contact.display_name.as_deref(), + contact.username.as_deref(), + ]; + haystacks + .iter() + .flatten() + .any(|field| field.to_lowercase().contains(needle_lowercase)) + || contact + .identity_id + .to_string(Encoding::Base58) + .to_lowercase() + .contains(needle_lowercase) +} + +/// Best label for a contact row: local nickname, then DashPay display name, +/// then DPNS username, then a shortened identity ID. Mirrors the hub-wide label +/// priority rule (IDH-003). +pub fn contact_label(contact: &ContactData) -> String { + let named = [ + contact.nickname.as_deref(), + contact.display_name.as_deref(), + contact.username.as_deref(), + ] + .into_iter() + .flatten() + .map(str::trim) + .find(|s| !s.is_empty()); + + match named { + Some(name) => name.to_string(), + None => abbreviate_id(&contact.identity_id.to_string(Encoding::Base58)), + } +} + +/// Shorten a Base58 identity ID for display: first 8 chars + "…". +pub fn abbreviate_id(id: &str) -> String { + if id.len() <= 10 { + id.to_string() + } else { + format!("{}…", &id[..8]) + } +} + +/// Pre-format a document's `created_at` as a human-relative timestamp. +fn relative_time(doc: &Document) -> Option { + let ts = doc.created_at().or_else(|| doc.updated_at())?; + crate::ui::dashpay::format_relative_time(ts) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(byte: u8) -> Identifier { + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") + } + + fn contact( + nickname: Option<&str>, + display: Option<&str>, + username: Option<&str>, + ) -> ContactData { + ContactData { + identity_id: id(7), + nickname: nickname.map(str::to_string), + note: None, + is_hidden: false, + account_reference: 0, + username: username.map(str::to_string), + display_name: display.map(str::to_string), + avatar_url: None, + bio: None, + } + } + + #[test] + fn claim_load_fires_once_per_tab_entry() { + let mut state = ContactsState::default(); + assert!(state.claim_load(), "first claim must dispatch the load"); + assert!(!state.claim_load(), "second claim must be debounced"); + + state.reset(); + assert!(state.claim_load(), "reset must re-arm the load"); + } + + #[test] + fn reset_clears_guard_lists_and_search() { + let mut state = ContactsState::default(); + state.claim_load(); + state.incoming.push(ContactRequestEntry { + counterpart_id: id(1), + request_id: id(2), + relative_time: None, + }); + state.outgoing.push(ContactRequestEntry { + counterpart_id: id(3), + request_id: id(4), + relative_time: None, + }); + state.record_contacts(vec![contact(Some("Bao"), None, None)]); + state.search.push_str("bao"); + + state.reset(); + + assert!(state.claim_load(), "reset must clear the load guard"); + assert!(state.incoming().is_empty(), "reset must clear incoming"); + assert!(state.outgoing().is_empty(), "reset must clear outgoing"); + assert_eq!(state.contacts_len(), 0, "reset must clear contacts"); + assert!(state.search_mut().is_empty(), "reset must clear the search"); + } + + #[test] + fn record_contacts_drops_hidden_contacts() { + let mut state = ContactsState::default(); + let mut hidden = contact(Some("Ghost"), None, None); + hidden.is_hidden = true; + state.record_contacts(vec![contact(Some("Bao"), None, None), hidden]); + + assert_eq!( + state.contacts_len(), + 1, + "hidden contacts must not be listed" + ); + assert_eq!( + state.filtered_contacts()[0].nickname.as_deref(), + Some("Bao") + ); + } + + #[test] + fn empty_search_matches_every_contact() { + let mut state = ContactsState::default(); + state.record_contacts(vec![ + contact(Some("Bao"), None, None), + contact(None, Some("Alex Kim"), None), + ]); + assert_eq!(state.filtered_contacts().len(), 2); + + state.search.push_str(" "); + assert_eq!( + state.filtered_contacts().len(), + 2, + "a whitespace-only query must not filter anything out" + ); + } + + #[test] + fn search_filters_by_nickname_display_name_and_username() { + let mut state = ContactsState::default(); + state.record_contacts(vec![ + contact(Some("Bao Tran"), None, None), + contact(None, Some("Alex Kim"), None), + contact(None, None, Some("priya.dash")), + ]); + + for (needle, expected) in [("bao", 1), ("ALEX", 1), ("priya", 1), ("a", 3)] { + *state.search_mut() = needle.to_string(); + assert_eq!( + state.filtered_contacts().len(), + expected, + "query '{needle}' must match {expected} contact(s)" + ); + } + } + + #[test] + fn search_filters_by_base58_identity_id() { + let mut state = ContactsState::default(); + state.record_contacts(vec![contact(None, None, None)]); + let base58 = id(7).to_string(Encoding::Base58); + + *state.search_mut() = base58[..6].to_string(); + assert_eq!( + state.filtered_contacts().len(), + 1, + "a contact with no profile must still be findable by its identity ID" + ); + } + + #[test] + fn search_with_no_match_returns_empty() { + let mut state = ContactsState::default(); + state.record_contacts(vec![contact(Some("Bao"), None, None)]); + *state.search_mut() = "zzzz".to_string(); + assert!(state.filtered_contacts().is_empty()); + } + + #[test] + fn remove_request_drops_the_row_from_both_lists() { + let mut state = ContactsState::default(); + state.incoming.push(ContactRequestEntry { + counterpart_id: id(1), + request_id: id(2), + relative_time: None, + }); + state.outgoing.push(ContactRequestEntry { + counterpart_id: id(3), + request_id: id(4), + relative_time: None, + }); + + state.remove_request(&id(2)); + assert!( + state.incoming().is_empty(), + "accepted/declined row must leave" + ); + assert_eq!(state.outgoing().len(), 1, "unrelated row must stay"); + + state.remove_request(&id(4)); + assert!(state.outgoing().is_empty(), "cancelled row must leave"); + } + + #[test] + fn contact_label_follows_nickname_display_username_id_priority() { + assert_eq!( + contact_label(&contact(Some("Bao"), Some("Alex Kim"), Some("alex.dash"))), + "Bao" + ); + assert_eq!( + contact_label(&contact(None, Some("Alex Kim"), Some("alex.dash"))), + "Alex Kim" + ); + assert_eq!( + contact_label(&contact(None, None, Some("alex.dash"))), + "alex.dash" + ); + + // No profile at all — fall back to the shortened identity ID. + let fallback = contact_label(&contact(None, None, None)); + assert_eq!(fallback, abbreviate_id(&id(7).to_string(Encoding::Base58))); + assert!(fallback.ends_with('…')); + } + + #[test] + fn contact_label_ignores_blank_profile_fields() { + assert_eq!( + contact_label(&contact(Some(" "), Some("Alex Kim"), None)), + "Alex Kim", + "a whitespace-only nickname must not win the label priority" + ); + } + + #[test] + fn abbreviate_id_shortens_long_ids_only() { + assert_eq!(abbreviate_id("AbCdEfGhIjKlMnOpQrStUv"), "AbCdEfGh…"); + assert_eq!(abbreviate_id("AbCdEfGh"), "AbCdEfGh"); + assert_eq!(abbreviate_id(""), ""); + } +} diff --git a/src/ui/state/mod.rs b/src/ui/state/mod.rs index 0d6a64f97..791c38c6b 100644 --- a/src/ui/state/mod.rs +++ b/src/ui/state/mod.rs @@ -7,6 +7,7 @@ pub mod account_summary; pub mod avatar_cache; +pub mod contacts_view; pub mod global_nav; pub mod hub_selection; pub mod masternodes_view; diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 01ff1e5b6..3794804c6 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -867,6 +867,18 @@ impl WalletBackend { .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) } + /// Whether `owner` has resolved the contact request from `counterparty_id` + /// locally — declined an incoming one, or withdrawn a sent one. Reads the + /// same owner-scoped marker [`dashpay_mark_rejected`](Self::dashpay_mark_rejected) + /// writes and [`DashpayView`] derives its status from. + /// + /// A `contactRequest` document is immutable and undeletable on Platform, so + /// this marker is the only thing that can retire a resolved request from a + /// listing. + pub fn dashpay_is_rejected(&self, owner: &Identifier, counterparty_id: &Identifier) -> bool { + kv_contains(&self.kv(), owner, KV_PREFIX_REJECTED, counterparty_id) + } + /// Write DET-local `(created_at_ms, updated_at_ms)` timestamps for an /// entity (contact, request, profile owner) into the k/v sidecar. These /// timestamps surface verbatim through the [`DashpayView`] adapter. From 3ab07d912b18a2191e5e1457566fc24eda276dbb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:20:21 +0000 Subject: [PATCH 07/30] fix(dashpay): clear a stale rejection marker when re-adding a contact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/user-stories.md | 30 ++++++++++++++++++++ src/backend_task/dashpay/contact_requests.rs | 28 ++++++++++++------ src/wallet_backend/dashpay.rs | 19 +++++++++++++ 3 files changed, 69 insertions(+), 8 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index af4904cfb..4242ced49 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -787,6 +787,16 @@ As a user, I want my contact list, their profiles, and their avatars to show ins - Contact profiles and avatar images are cached locally and served on subsequent views. - An explicit "Refresh" action re-fetches the latest profiles and avatars from the network. +### DPY-014: Cancel a sent contact request [Implemented] +**Persona:** Alex, Priya + +As a user, I want to cancel a contact request I sent so that it stops sitting in my list when I no longer expect an answer. + +- A DashPay contact request is immutable on Platform and cannot be deleted, so cancelling cannot un-send it. The UI states this plainly rather than implying a withdrawal. +- Cancelling re-checks the request against the network first: it must still exist, must have been sent by the acting identity, and must not have already been answered. +- Cancelling publishes a hidden contact-info document and records the withdrawal locally, so the request leaves the sent list and stays gone across restarts. +- A request the other person already accepted is reported as an established contact instead of being cancelled. + --- ## Token Operations (TOK) @@ -1387,6 +1397,26 @@ As any persona, my payments, funding movements, and platform actions all live in - Activity tab shell ships with filter chips; a reusable row component for rendering timeline entries will be added once the aggregator lands. - Full aggregation across DashPay payments, funding, and platform ops depends on a backend aggregator; gated behind the `identity-hub-activity-feed` Cargo feature until implemented. +### IDH-007: Manage contacts from the Identities hub [Implemented] +**Persona:** Alex, Priya + +As a user, I want to handle my contacts entirely from the Identities hub — answer requests, find a contact, and pay them — without detouring through a separate DashPay screen. + +- Received requests offer Accept and Decline; both act on the request and the row leaves the list. +- Sent requests offer Cancel, which withdraws the request (see DPY-014). +- Established contacts are listed with a search box that filters them by nickname, display name, username, or identity ID. +- Each contact row offers Pay, which opens the existing send-payment flow for that contact. +- Contacts the user has hidden do not appear in the list. + +### IDH-008: Name an identity on this device [Implemented] +**Persona:** Alex, Priya + +As a user with more than one identity, I want to give an identity a name only I see so that I can tell my identities apart without registering a username. + +- Settings tab hosts the name field; the copy states that the name stays on the device and is never published. +- Saving is only offered when the name actually changed, and clearing the field removes the name. +- The saved name is what the breadcrumb and identity pills show, in preference to the username or the raw identity ID. + ## Masternodes (MN) ### MN-001: Load a masternode by keys [Implemented] diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index eb0bb9edd..c301fc08e 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -599,16 +599,28 @@ pub async fn send_contact_request_with_proof( core_height_created_at, created_at_ts, ); - if let Ok(backend) = app_context.wallet_backend() - && let Err(err) = backend + if let Ok(backend) = app_context.wallet_backend() { + if let Err(err) = backend .record_sent_contact_request(&seed_hash, &owner_id, contact_record) .await - { - tracing::warn!( - %err, - "record_sent_contact_request failed; contact was sent but \ - local wallet-manager state not updated", - ); + { + tracing::warn!( + %err, + "record_sent_contact_request failed; contact was sent but \ + local wallet-manager state not updated", + ); + } + + // Sending to someone retires an earlier decline or withdrawal of theirs: + // the user has deliberately re-engaged, so their requests must stop being + // filtered out of the list. + if let Err(err) = backend.dashpay_unmark_rejected(&owner_id, &to_identity_id) { + tracing::debug!( + %err, + "Clearing the stale rejection marker failed; an earlier declined \ + request from this person may stay hidden until the next decline is cleared", + ); + } } Ok(BackendTaskSuccessResult::DashPayContactRequestSent( diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 3794804c6..3828ba401 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -879,6 +879,25 @@ impl WalletBackend { kv_contains(&self.kv(), owner, KV_PREFIX_REJECTED, counterparty_id) } + /// Clear `owner`'s rejection marker for `counterparty_id`. Idempotent — + /// clearing an absent marker is `Ok(())`. + /// + /// Sending a contact request to someone is an explicit re-engagement, so it + /// retires an earlier decline or withdrawal. Without this, a request the + /// user once declined would stay filtered out of their list forever, even + /// after they deliberately added that person again. + pub fn dashpay_unmark_rejected( + &self, + owner: &Identifier, + counterparty_id: &Identifier, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(KV_PREFIX_REJECTED, counterparty_id); + self.kv() + .delete(DetScope::Identity(&owner_buf), &key) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + /// Write DET-local `(created_at_ms, updated_at_ms)` timestamps for an /// entity (contact, request, profile owner) into the k/v sidecar. These /// timestamps surface verbatim through the [`DashpayView`] adapter. From 43865314f6fba068f3ee1fee86514557a8862614 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:28:06 +0000 Subject: [PATCH 08/30] fix(migration): import settings, scheduled votes and top-ups from legacy data.db MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 11 + docs/user-stories.md | 8 +- src/app.rs | 20 + src/backend_task/migration/finish_unwire.rs | 488 +++++++++++- src/backend_task/migration/legacy_settings.rs | 287 +++++++ src/backend_task/migration/mod.rs | 1 + src/context/migration_status.rs | 3 + src/database/initialization.rs | 45 ++ src/database/legacy_import.rs | 707 ++++++++++++++++++ src/database/mod.rs | 1 + src/model/settings.rs | 23 +- tests/kittest/migration_banner.rs | 1 + 12 files changed, 1570 insertions(+), 25 deletions(-) create mode 100644 src/backend_task/migration/legacy_settings.rs create mode 100644 src/database/legacy_import.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc9de5ba..1dd94f6d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,6 +138,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- **Your settings and scheduled votes now survive an upgrade**: upgrading from an + earlier version no longer starts the app with a blank configuration. The first + launch after the upgrade brings across your selected network, start screen, + theme, onboarding state, Dash-Qt path and the remaining toggles — so a testnet + user is no longer relaunched on Mainnet — along with your scheduled DPNS votes + (choice, time and already-cast state) and your identities' top-up history. + Scheduled votes are imported even on an install whose wallets were already + moved by a previous launch. If a scheduled vote cannot be read, the app says so + in a banner with a "Retry now" action instead of dropping it silently; the + original data is never deleted from the previous version's storage. + - **Expert mode now reveals the Masternodes tab without a restart**: turning on Expert mode in Settings immediately shows the "Masternodes" entry in the left nav. Previously the Expert-mode flag was stored separately per network, so the diff --git a/docs/user-stories.md b/docs/user-stories.md index af4904cfb..769ee8248 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -671,13 +671,13 @@ As a user, I want to assign a friendly alias to an identity behind a username I - Alias set from the "My usernames" table. - Alias persists and is applied to the underlying identity. -### DPN-009: Scheduled votes preserved across an app upgrade [Gap] +### DPN-009: Scheduled votes preserved across an app upgrade [Implemented] **Persona:** Priya As a masternode operator, I want my previously scheduled DPNS votes to survive an app upgrade so that I do not miss a contest's vote window after updating. - Scheduled votes stored before the upgrade remain visible and executable afterward. -- Note (PROJ-034): not migrated — legacy scheduled votes start empty after the platform-wallet upgrade, risking missed vote windows for masternode voters. +- The first launch after the upgrade imports them from the previous version's storage, keeping each vote's choice, timestamp, and already-cast state. A vote that cannot be read is reported in a banner rather than dropped silently. --- @@ -1239,13 +1239,13 @@ As an expert user, I want to clear the cached SPV headers and filter data for a - Expert-mode "Clear SPV Data" action with confirmation; disabled while SPV is active. - The next connection triggers a full resync. -### NET-021: App settings preserved across an app upgrade [Gap] +### NET-021: App settings preserved across an app upgrade [Implemented] **Persona:** Alex, Priya, Jordan As a user, I want my saved settings — selected network, theme, onboarding state, and paths — to survive an app upgrade so that I do not silently relaunch into the wrong network or a reset configuration. - Settings stored before the upgrade remain applied afterward. -- Note (PROJ-034): not migrated — existing users get default AppSettings on first launch after the platform-wallet upgrade (network resets to Mainnet; theme, onboarding, and paths reset); no importer exists (`db.get_settings` has zero callers). Scheduled-vote loss is tracked separately in DPN-009. +- The first launch after the upgrade imports the saved network, start screen, theme, onboarding state, Dash-Qt path, and the remaining toggles before the network is selected, so a testnet user is never relaunched on Mainnet. Top-up history is imported alongside the scheduled votes of DPN-009. --- diff --git a/src/app.rs b/src/app.rs index d080297be..8eeb4a127 100644 --- a/src/app.rs +++ b/src/app.rs @@ -127,6 +127,7 @@ fn spv_block_step(armed: bool, dismissed: bool, state: OverallConnectionState) - pub fn migration_running_text(step: MigrationStep) -> &'static str { match step { MigrationStep::Detecting => "Checking your wallet data.", + MigrationStep::AppData => "Restoring your scheduled votes.", MigrationStep::SingleKey => "Updating imported keys.", MigrationStep::Shielded => "Verifying shielded balance.", MigrationStep::WalletSeeds => "Moving your wallets into the new vault.", @@ -544,6 +545,23 @@ impl AppState { // handed to every per-network `AppContext`. The seed vault was opened // by the caller (keyless, or with a recovered legacy passphrase). let app_kv = AppContext::open_app_kv(&data_dir)?; + + // Carry an upgrading user's preferences (network, theme, onboarding) + // out of legacy `data.db` before they are read below. This has to run + // here, ahead of the read: the active network is chosen from the blob + // a few lines down, and booting a testnet user onto mainnet is a + // safety hazard. A failure is not fatal — the boot continues on + // defaults and the (unwritten) sentinel makes the next launch retry. + match crate::backend_task::migration::legacy_settings::import_legacy_settings(&app_kv, &db) + { + Ok(outcome) => tracing::debug!(?outcome, "Legacy settings import"), + Err(e) => tracing::warn!( + error = ?e, + "Could not import preferences from the previous version — using defaults; \ + the next launch retries", + ), + } + let settings = match app_kv.get::(DetScope::Global, AppSettings::KV_KEY) { Ok(Some(s)) => s, Ok(None) => AppSettings::default(), @@ -1804,6 +1822,7 @@ mod migration_banner_tests { fn migration_running_text_is_sentence_for_every_step() { for step in [ MigrationStep::Detecting, + MigrationStep::AppData, MigrationStep::SingleKey, MigrationStep::Shielded, MigrationStep::WalletSeeds, @@ -1826,6 +1845,7 @@ mod migration_banner_tests { fn migration_running_text_distinct_per_step() { let labels = [ migration_running_text(MigrationStep::Detecting), + migration_running_text(MigrationStep::AppData), migration_running_text(MigrationStep::SingleKey), migration_running_text(MigrationStep::Shielded), migration_running_text(MigrationStep::WalletSeeds), diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index edc7cad62..a20d17505 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -38,14 +38,21 @@ pub fn sentinel_key_for(network: Network) -> String { ) } -// TODO: App settings, top-up history, and scheduled DPNS votes all reset/empty on -// upgrade — confirmed real data loss per v0.9.3 cross-check; follow-up priority: -// scheduled votes (vote-window deadline risk) > app settings (UX friction) > top-up -// history (audit trail). Migration to be handled in a separate PR. /// Tables sniffed during detection. Any non-empty row count flips the /// migration into the `Running` state. Ordered so the cheapest check /// (the single-row `wallet` table) runs first. -const LEGACY_TABLES: &[&str] = &["wallet", "single_key_wallet", "utxos"]; +/// +/// `scheduled_votes` and `top_up` are in the list because an install can +/// hold them with no wallet rows at all — a masternode voter who imported +/// identity keys directly has queued votes but no HD wallet. Omitting them +/// would leave the detection gate closed and drop those votes. +const LEGACY_TABLES: &[&str] = &[ + "wallet", + "single_key_wallet", + "utxos", + "scheduled_votes", + "top_up", +]; /// Persisted sentinel payload. Lives in `det-app.sqlite` under the /// per-network sentinel key returned by [`sentinel_key_for`]. @@ -149,6 +156,29 @@ pub enum MigrationError { failed: u32, }, + /// At least one legacy `scheduled_votes` row could not be decoded into a + /// usable vote. Fatal: a scheduled vote that disappears without a word is + /// a missed vote window, so the sentinel stays unwritten and the failure + /// reaches the user's banner. The legacy row is never deleted, so a fixed + /// build can still recover it. + #[error("could not import {unreadable} scheduled vote(s) from the previous version")] + ScheduledVotesUnreadable { + /// Votes that were imported into the k/v store on this pass. + imported: u32, + /// Votes that could not be decoded (corrupt voter id or vote choice). + unreadable: u32, + }, + + /// The decoded scheduled votes could not be written into the k/v store. + /// Fatal for the same reason as [`Self::ScheduledVotesUnreadable`] — the + /// sentinel stays unwritten so the next launch retries the idempotent + /// import rather than leaving the votes behind. + #[error("could not save scheduled votes from the previous version")] + ScheduledVotesWrite { + #[source] + source: Box, + }, + /// The wallet backend was not yet wired when the migration ran. /// This is a hard configuration bug: the orchestrator runs after /// `ensure_wallet_backend`, so this should never fire in @@ -222,6 +252,16 @@ pub async fn run(app_context: &Arc) -> Result { let app_kv = app_context.app_kv(); let network = app_context.network; + // Scheduled votes and top-up history carry their own sentinel and run + // ahead of the wallet-drain gate below: an install that already completed + // the wallet drain under an earlier build (which had no app-data import) + // still has those rows in `data.db`, and the wallet sentinel would + // otherwise short-circuit the launch and strand them. + status.set_state(MigrationState::Running { + step: MigrationStep::AppData, + }); + let app_data_moved = migrate_app_data(app_context)?; + // Idempotency: if the sentinel for *this network* already exists, // this launch has nothing to do. The sentinel is per-network // because every migration body filters legacy rows by `WHERE @@ -236,11 +276,11 @@ pub async fn run(app_context: &Arc) -> Result { network_count = completion.network_count, "FinishUnwire already completed for this network — skipping", ); - // No-op launch: the sentinel was already written by a prior run, so - // nothing moved this time. Stay `Idle` so the per-frame banner - // reconciler never surfaces a spurious "storage update complete". - status.set_state(MigrationState::Idle); - return Ok(false); + // The wallet drain was already done by a prior run. The app-data pass + // above may still have moved votes on this launch — report its outcome + // so the completion banner appears exactly when work happened. + status.set_state(terminal_state(app_data_moved)); + return Ok(app_data_moved); } status.set_state(MigrationState::Running { @@ -255,11 +295,11 @@ pub async fn run(app_context: &Arc) -> Result { "No legacy data.db rows detected — writing sentinel without migration", ); write_sentinel(&app_kv, network, 0)?; - // No legacy rows to move (e.g. a fresh install): record the sentinel - // but stay `Idle` so no completion banner appears for a launch that - // did no work. - status.set_state(MigrationState::Idle); - return Ok(false); + // No wallet rows to move (e.g. a fresh install): record the sentinel. + // The banner still follows the app-data pass, which runs even when the + // wallet tables are empty. + status.set_state(terminal_state(app_data_moved)); + return Ok(app_data_moved); } tracing::info!( @@ -313,6 +353,17 @@ pub async fn run(app_context: &Arc) -> Result { Ok(true) } +/// Terminal state for a launch that reached the end without failing. +/// `Success` raises the completion banner, so it is reserved for launches +/// that actually moved data — a no-op launch stays `Idle`. +fn terminal_state(moved_data: bool) -> MigrationState { + if moved_data { + MigrationState::Success + } else { + MigrationState::Idle + } +} + /// Re-hydrates just-migrated wallets into `ctx.wallets` and registers the /// resolvable (open/unprotected) ones upstream. [`run`] calls this /// immediately before [`write_sentinel`], so completion can never be @@ -348,6 +399,202 @@ async fn register_migrated_wallets(app_context: &Arc) -> Result<(), Ok(()) } +/// Per-network sentinel for the DET app-data import (scheduled votes and +/// top-up history). Separate from the wallet-drain sentinel on purpose: an +/// install that already completed the wallet drain under an earlier build +/// still has its votes sitting in `data.db`, and a shared sentinel would +/// declare that install "done" and drop them. +pub fn app_data_sentinel_key_for(network: Network) -> String { + format!("det:migration:app_data:{}:v1", network_prefix(network)) +} + +/// Import the DET-owned rows the wallet drain never touched: scheduled DPNS +/// votes (deadline-critical) and top-up history (audit trail). +/// +/// Returns `true` when this pass moved data. Idempotent — votes already in +/// the k/v store are left alone, so a retry can never overwrite a vote the +/// user has since cast with its stale legacy `executed` flag. +/// +/// # Errors +/// +/// [`MigrationError::ScheduledVotesUnreadable`] when a legacy vote row cannot +/// be decoded. The sentinel stays unwritten and the error reaches the banner: +/// a scheduled vote that vanishes without a word is a missed vote window. +fn migrate_app_data(app_context: &Arc) -> Result { + let app_kv = app_context.app_kv(); + let network = app_context.network; + let sentinel_key = app_data_sentinel_key_for(network); + + let done = app_kv + .get::(DetScope::Global, &sentinel_key) + .map_err(|source| MigrationError::Sentinel { source })? + .is_some(); + if done { + return Ok(false); + } + + let Some(path) = app_context.db.db_file_path() else { + // In-memory / headless: no legacy file to import from. + return Ok(false); + }; + if !path.exists() { + return Ok(false); + } + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path.to_string_lossy().to_string(), + source: e, + })?; + + // Probe before reaching for the wallet backend: an install with nothing to + // import (a fresh one, or any build after C5 stopped creating these tables) + // must complete without the backend being wired, or a cold start with no + // legacy data would fail on a dependency it never actually needs. + if !table_has_rows(&conn, "scheduled_votes")? && !table_has_rows(&conn, "top_up")? { + write_app_data_sentinel(&app_kv, &sentinel_key)?; + return Ok(false); + } + + // There is data to move, so the k/v store — and therefore the backend — + // is genuinely required now. An unwired backend is transient: the + // cold-start dispatcher retries this variant on a later frame. + app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + + // Votes already in the k/v store win over their legacy row: a retry must + // not push a stale `executed = 0` over a vote the user has since cast. + let existing_votes: std::collections::BTreeSet<([u8; 32], String)> = app_context + .get_scheduled_votes()? + .into_iter() + .map(|v| (v.voter_id.to_buffer(), v.contested_name)) + .collect(); + + let outcome = migrate_app_data_from_conn( + &conn, + network, + &existing_votes, + |votes| app_context.insert_scheduled_votes(votes), + |id, top_ups| app_context.save_top_ups(id, top_ups), + )?; + + tracing::info!( + target = "migration::finish_unwire", + votes_imported = outcome.votes_imported, + votes_skipped_existing = outcome.votes_skipped_existing, + top_up_identities_imported = outcome.top_up_identities_imported, + network = ?network, + "App-data migration pass complete", + ); + + write_app_data_sentinel(&app_kv, &sentinel_key)?; + + Ok(outcome.votes_imported > 0 || outcome.top_up_identities_imported > 0) +} + +/// Record the app-data import as complete for one network. Reuses the +/// [`MigrationCompletion`] payload so both sentinels share a codec. +fn write_app_data_sentinel( + app_kv: &crate::wallet_backend::DetKv, + sentinel_key: &str, +) -> Result<(), MigrationError> { + let completion = MigrationCompletion { + completed_at: now_epoch_seconds(), + sha: env!("CARGO_PKG_VERSION").to_string(), + network_count: 1, + }; + app_kv + .put(DetScope::Global, sentinel_key, &completion) + .map_err(|source| MigrationError::Sentinel { source }) +} + +/// Pure app-data migration body (testable without an `AppContext`). +/// +/// `existing_votes` holds the `(voter, contested_name)` pairs already in the +/// k/v store; those rows are skipped so a retry cannot overwrite a vote the +/// user has since cast with the stale legacy `executed` flag. +fn migrate_app_data_from_conn( + conn: &Connection, + network: dash_sdk::dpp::dashcore::Network, + existing_votes: &std::collections::BTreeSet<([u8; 32], String)>, + insert_votes: I, + mut save_top_ups: T, +) -> Result +where + I: FnOnce(&[crate::backend_task::contested_names::ScheduledDPNSVote]) -> Result<(), TaskError>, + T: FnMut( + &dash_sdk::platform::Identifier, + &std::collections::BTreeMap, + ) -> Result<(), TaskError>, +{ + let mut outcome = AppDataMigrationOutcome::default(); + + let legacy_votes = crate::database::legacy_import::read_scheduled_votes(conn, network) + .map_err(|source| MigrationError::LegacyDbRead { + table: "scheduled_votes", + source, + })?; + outcome.votes_unreadable = legacy_votes.unreadable; + + let to_import: Vec<_> = legacy_votes + .votes + .into_iter() + .filter(|v| { + let known = + existing_votes.contains(&(v.voter_id.to_buffer(), v.contested_name.clone())); + if known { + outcome.votes_skipped_existing = outcome.votes_skipped_existing.saturating_add(1); + } + !known + }) + .collect(); + + if !to_import.is_empty() { + insert_votes(&to_import).map_err(|source| MigrationError::ScheduledVotesWrite { + source: Box::new(source), + })?; + outcome.votes_imported = u32::try_from(to_import.len()).unwrap_or(u32::MAX); + } + + // Top-ups are audit trail, not funds: a read failure must not strand the + // votes that already landed, so it is logged and skipped rather than + // failing the pass. + match crate::database::legacy_import::read_top_ups(conn, network) { + Ok(top_ups) => { + for (identity_id, history) in top_ups { + let id = dash_sdk::platform::Identifier::from(identity_id); + match save_top_ups(&id, &history) { + Ok(()) => { + outcome.top_up_identities_imported = + outcome.top_up_identities_imported.saturating_add(1) + } + Err(e) => tracing::warn!( + target = "migration::finish_unwire", + identity = %hex::encode(identity_id), + error = ?e, + "Could not write top-up history; the audit trail stays in legacy data.db", + ), + } + } + } + Err(e) => tracing::warn!( + target = "migration::finish_unwire", + error = ?e, + "Could not read legacy top-up history; the audit trail stays in legacy data.db", + ), + } + + // Votes last: everything readable is safely in the k/v store before the + // pass reports failure, so a retry only has the corrupt rows left to face. + if outcome.votes_unreadable > 0 { + return Err(MigrationError::ScheduledVotesUnreadable { + imported: outcome.votes_imported, + unreadable: outcome.votes_unreadable, + }); + } + + Ok(outcome) +} + /// Returns `true` when any of the [`LEGACY_TABLES`] holds at least one /// row. Missing tables are treated as empty: a freshly-installed /// `data.db` already lacks the dropped tables, and that is correct. @@ -403,6 +650,23 @@ fn table_has_rows(conn: &Connection, table: &'static str) -> Result Connection { + let conn = Connection::open_in_memory().expect("open"); + conn.execute_batch( + "CREATE TABLE scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0, + network TEXT NOT NULL, + PRIMARY KEY (identity_id, contested_name) + ); + CREATE TABLE identity (id BLOB PRIMARY KEY, network TEXT NOT NULL); + CREATE TABLE top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (identity_id, top_up_index) + );", + ) + .expect("schema"); + conn.execute( + "INSERT INTO identity (id, network) VALUES (?1, 'testnet')", + rusqlite::params![VOTER.as_slice()], + ) + .expect("identity"); + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, 'alice', 'Lock', 1700000000, 0, 'testnet')", + rusqlite::params![VOTER.as_slice()], + ) + .expect("vote"); + conn.execute( + "INSERT INTO top_up (identity_id, top_up_index, amount) VALUES (?1, 0, 5000)", + rusqlite::params![VOTER.as_slice()], + ) + .expect("top up"); + conn + } + + /// The core promise: a queued vote survives the upgrade. Losing it + /// means a masternode voter silently misses a vote window. + #[test] + fn imports_scheduled_votes_and_top_ups() { + let conn = legacy_conn(); + let votes = RefCell::new(Vec::new()); + let top_ups = RefCell::new(Vec::new()); + + let outcome = migrate_app_data_from_conn( + &conn, + Network::Testnet, + &BTreeSet::new(), + |v| { + votes.borrow_mut().extend_from_slice(v); + Ok(()) + }, + |id, map| { + top_ups.borrow_mut().push((*id, map.clone())); + Ok(()) + }, + ) + .expect("import"); + + assert_eq!(outcome.votes_imported, 1); + assert_eq!(outcome.votes_unreadable, 0); + assert_eq!(outcome.top_up_identities_imported, 1); + + let votes = votes.borrow(); + assert_eq!(votes.len(), 1); + assert_eq!(votes[0].contested_name, "alice"); + assert_eq!(votes[0].choice, ResourceVoteChoice::Lock); + assert_eq!(votes[0].voter_id, Identifier::from(VOTER)); + assert!(!votes[0].executed_successfully); + + let top_ups = top_ups.borrow(); + assert_eq!(top_ups.len(), 1); + assert_eq!(top_ups[0].1, BTreeMap::from([(0, 5000)])); + } + + /// A retry must not overwrite a vote the user already cast in the new + /// build — the legacy row still says `executed = 0`, so re-importing + /// it would queue the vote a second time. + #[test] + fn skips_votes_already_present_in_the_kv_store() { + let conn = legacy_conn(); + let existing = BTreeSet::from([(VOTER, "alice".to_string())]); + let votes = RefCell::new(Vec::new()); + + let outcome = migrate_app_data_from_conn( + &conn, + Network::Testnet, + &existing, + |v| { + votes.borrow_mut().extend_from_slice(v); + Ok(()) + }, + |_, _| Ok(()), + ) + .expect("import"); + + assert_eq!(outcome.votes_imported, 0); + assert_eq!(outcome.votes_skipped_existing, 1); + assert!( + votes.borrow().is_empty(), + "an already-migrated vote must not be re-queued", + ); + } + + /// An undecodable vote row is surfaced as a failure, never dropped in + /// silence — the user must learn their vote did not come across. + #[test] + fn unreadable_vote_row_fails_the_import() { + let conn = legacy_conn(); + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, 'corrupt', 'Nonsense', 1, 0, 'testnet')", + rusqlite::params![VOTER.as_slice()], + ) + .expect("corrupt vote"); + + let err = migrate_app_data_from_conn( + &conn, + Network::Testnet, + &BTreeSet::new(), + |_| Ok(()), + |_, _| Ok(()), + ) + .expect_err("an unreadable vote must fail the import"); + + assert!( + matches!( + err, + MigrationError::ScheduledVotesUnreadable { + imported: 1, + unreadable: 1 + } + ), + "unexpected error: {err:?}", + ); + } + + /// A fresh install has none of these tables — a no-op, not an error. + #[test] + fn missing_tables_are_a_no_op() { + let conn = Connection::open_in_memory().expect("open"); + + let outcome = migrate_app_data_from_conn( + &conn, + Network::Testnet, + &BTreeSet::new(), + |_| Ok(()), + |_, _| Ok(()), + ) + .expect("import"); + + assert_eq!(outcome, AppDataMigrationOutcome::default()); + } + + /// The app-data sentinel is per network and distinct from the + /// wallet-drain sentinel: an install that already drained its wallets + /// under an earlier build must still import its votes. + #[test] + fn sentinel_is_per_network_and_distinct_from_the_wallet_sentinel() { + let testnet = app_data_sentinel_key_for(Network::Testnet); + assert_ne!(testnet, app_data_sentinel_key_for(Network::Mainnet)); + assert_ne!(testnet, sentinel_key_for(Network::Testnet)); + } + + /// Votes and top-ups can exist with no wallet rows at all (a + /// masternode voter who imported identity keys directly), so the + /// detection gate must sniff their tables too. + #[test] + fn detection_gate_covers_the_app_data_tables() { + assert!(LEGACY_TABLES.contains(&"scheduled_votes")); + assert!(LEGACY_TABLES.contains(&"top_up")); + } + } + /// Round-trip: writing the sentinel and reading it back yields the /// same payload. Guards the codec from accidental shape drift. #[test] diff --git a/src/backend_task/migration/legacy_settings.rs b/src/backend_task/migration/legacy_settings.rs new file mode 100644 index 000000000..9566ce740 --- /dev/null +++ b/src/backend_task/migration/legacy_settings.rs @@ -0,0 +1,287 @@ +//! One-shot boot import of the legacy `settings` row into [`AppSettings`]. +//! +//! The unwire moved user preferences into the app k/v store but never +//! carried the existing `data.db` row across, so an upgrading user booted +//! with `AppSettings::default()`: mainnet, System theme, onboarding +//! un-completed. Relaunching a testnet user on mainnet is a safety hazard, +//! not a cosmetic reset — this import closes that gap. +//! +//! Unlike [`finish_unwire`](super::finish_unwire) this is **not** a backend +//! task. The active network is chosen in `AppState::new_inner` from the +//! settings blob, before any `AppContext` (and therefore any async runtime) +//! exists, so the import has to run synchronously at that point or the boot +//! would already have picked the wrong network. + +use dash_sdk::dpp::dashcore::Network; +use serde::{Deserialize, Serialize}; + +use crate::database::Database; +use crate::model::settings::AppSettings; +use crate::wallet_backend::{DetKv, DetScope, KvAdapterError}; + +/// Sentinel marking the settings import as done for this install. Global — +/// [`AppSettings`] is a single cross-network blob, so unlike the +/// [`finish_unwire`](super::finish_unwire) sentinel this one is not +/// per-network. Versioned so a future format change bumps the key. +const SENTINEL_KEY: &str = "det:migration:legacy_settings:v1"; + +/// What the import did on this launch. Returned so the caller can log it and +/// tests can assert the branch taken. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SettingsImport { + /// The sentinel was already present — a previous launch imported (or + /// found nothing to import). Never touches the user's current settings. + AlreadyDone, + /// No legacy `settings` row existed (fresh install). Sentinel written so + /// later launches skip the probe. + NoLegacyData, + /// Preferences were carried across. Carries the network that was + /// restored, which is the safety-critical field. + Imported { network: Network }, +} + +/// Failure of the settings import. The caller boots with defaults on error +/// and leaves the sentinel unwritten, so the next launch retries. +#[derive(Debug, thiserror::Error)] +pub enum SettingsImportError { + /// The legacy `settings` row could not be read from `data.db`. + #[error("could not read legacy settings")] + LegacyRead { + #[source] + source: rusqlite::Error, + }, + + /// The imported settings, or the sentinel, could not be written to the + /// app k/v store. + #[error("could not write imported settings")] + Write { + #[source] + source: KvAdapterError, + }, +} + +/// Carry the legacy `settings` row into the app k/v store, once per install. +/// +/// Idempotent: the [`SENTINEL_KEY`] short-circuits every launch after the +/// first. The import deliberately **overwrites** any settings blob already +/// present, because until this import shipped an upgrading user's first +/// launch wrote a `default()` blob (mainnet) over their real preferences — +/// skipping on "a blob exists" would make that reset permanent. +/// +/// # Errors +/// +/// Returns [`SettingsImportError`] when `data.db` cannot be read or the k/v +/// store cannot be written. The sentinel stays unwritten in both cases, so +/// the next launch retries rather than silently keeping the defaults. +pub fn import_legacy_settings( + app_kv: &DetKv, + db: &Database, +) -> Result { + if sentinel_present(app_kv)? { + return Ok(SettingsImport::AlreadyDone); + } + + let legacy = db + .read_legacy_app_settings() + .map_err(|source| SettingsImportError::LegacyRead { source })?; + + let Some(settings) = legacy else { + write_sentinel(app_kv)?; + tracing::debug!( + target = "migration::legacy_settings", + "No legacy settings row — nothing to import", + ); + return Ok(SettingsImport::NoLegacyData); + }; + + let network = settings.network; + app_kv + .put(DetScope::Global, AppSettings::KV_KEY, &settings) + .map_err(|source| SettingsImportError::Write { source })?; + write_sentinel(app_kv)?; + + tracing::info!( + target = "migration::legacy_settings", + network = ?network, + theme = ?settings.theme_mode, + onboarding_completed = settings.onboarding_completed, + "Imported preferences from the previous version", + ); + Ok(SettingsImport::Imported { network }) +} + +/// Marker payload for [`SENTINEL_KEY`]. A struct rather than a bare `bool` so +/// the payload can gain diagnostics fields without a key bump. +#[derive(Debug, Serialize, Deserialize)] +struct SettingsImportSentinel { + /// Version tag of the build that ran the import. + sha: String, +} + +fn sentinel_present(app_kv: &DetKv) -> Result { + app_kv + .get::(DetScope::Global, SENTINEL_KEY) + .map(|v| v.is_some()) + .map_err(|source| SettingsImportError::Write { source }) +} + +fn write_sentinel(app_kv: &DetKv) -> Result<(), SettingsImportError> { + let sentinel = SettingsImportSentinel { + sha: env!("CARGO_PKG_VERSION").to_string(), + }; + app_kv + .put(DetScope::Global, SENTINEL_KEY, &sentinel) + .map_err(|source| SettingsImportError::Write { source }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::settings::{RootScreenType, ThemeMode}; + use crate::wallet_backend::kv_test_support::InMemoryKv; + use std::sync::Arc; + + fn kv() -> DetKv { + DetKv::from_store(Arc::new(InMemoryKv::default())) + } + + /// A `data.db` shaped like a v0.10-dev install whose owner runs on + /// testnet with a dark theme and finished onboarding. + fn legacy_db(dir: &std::path::Path) -> Database { + let db = Database::new(dir.join("data.db")).expect("open db"); + { + let conn = db.locked_conn(); + conn.execute_batch( + "CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + network TEXT NOT NULL, + start_root_screen INTEGER NOT NULL, + theme_preference TEXT, + onboarding_completed INTEGER, + database_version INTEGER NOT NULL + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO settings + (id, network, start_root_screen, theme_preference, onboarding_completed, + database_version) + VALUES (1, 'testnet', ?1, 'Dark', 1, 40)", + rusqlite::params![RootScreenType::RootScreenDPNSScheduledVotes.to_int()], + ) + .unwrap(); + } + db + } + + fn stored(app_kv: &DetKv) -> Option { + app_kv + .get::(DetScope::Global, AppSettings::KV_KEY) + .expect("read settings blob") + } + + /// The headline regression: an upgrading testnet user must not relaunch + /// on mainnet. Also proves theme and the onboarding flag come across. + #[test] + fn import_restores_network_theme_and_onboarding() { + let dir = tempfile::tempdir().unwrap(); + let db = legacy_db(dir.path()); + let app_kv = kv(); + + let outcome = import_legacy_settings(&app_kv, &db).expect("import"); + + assert_eq!( + outcome, + SettingsImport::Imported { + network: Network::Testnet + } + ); + let settings = stored(&app_kv).expect("settings written"); + assert_eq!( + settings.network, + Network::Testnet, + "a testnet user must not be relaunched on mainnet", + ); + assert_eq!(settings.theme_mode, ThemeMode::Dark); + assert!(settings.onboarding_completed); + assert_eq!( + settings.root_screen_type, + RootScreenType::RootScreenDPNSScheduledVotes + ); + } + + /// The first launch after upgrading (before this import existed) wrote a + /// `default()` blob over the user's preferences. The import must repair + /// that, not treat the default blob as a user choice worth keeping. + #[test] + fn import_overwrites_the_default_blob_a_prior_launch_wrote() { + let dir = tempfile::tempdir().unwrap(); + let db = legacy_db(dir.path()); + let app_kv = kv(); + app_kv + .put( + DetScope::Global, + AppSettings::KV_KEY, + &AppSettings::default(), + ) + .expect("seed default blob"); + assert_eq!(stored(&app_kv).unwrap().network, Network::Mainnet); + + import_legacy_settings(&app_kv, &db).expect("import"); + + assert_eq!( + stored(&app_kv).unwrap().network, + Network::Testnet, + "the reset-to-mainnet blob must be repaired from the legacy row", + ); + } + + /// Once imported, later launches must never clobber settings the user has + /// since changed — the sentinel, not the blob's contents, is the guard. + #[test] + fn import_is_a_no_op_once_the_sentinel_exists() { + let dir = tempfile::tempdir().unwrap(); + let db = legacy_db(dir.path()); + let app_kv = kv(); + + import_legacy_settings(&app_kv, &db).expect("first import"); + + // The user switches to mainnet after the import. + let mut chosen = stored(&app_kv).expect("settings"); + chosen.network = Network::Mainnet; + app_kv + .put(DetScope::Global, AppSettings::KV_KEY, &chosen) + .expect("user changes network"); + + let outcome = import_legacy_settings(&app_kv, &db).expect("second import"); + + assert_eq!(outcome, SettingsImport::AlreadyDone); + assert_eq!( + stored(&app_kv).unwrap().network, + Network::Mainnet, + "a re-run must not resurrect the legacy network over the user's choice", + ); + } + + /// A fresh install has no legacy row. The import writes the sentinel so + /// the probe does not repeat, and leaves the settings blob alone. + #[test] + fn fresh_install_records_the_sentinel_without_writing_settings() { + let dir = tempfile::tempdir().unwrap(); + let db = Database::new(dir.path().join("data.db")).expect("open db"); + let app_kv = kv(); + + let outcome = import_legacy_settings(&app_kv, &db).expect("import"); + + assert_eq!(outcome, SettingsImport::NoLegacyData); + assert!( + stored(&app_kv).is_none(), + "no legacy data must not fabricate a settings blob", + ); + assert_eq!( + import_legacy_settings(&app_kv, &db).expect("second import"), + SettingsImport::AlreadyDone, + "the sentinel must stop the probe from repeating", + ); + } +} diff --git a/src/backend_task/migration/mod.rs b/src/backend_task/migration/mod.rs index 36a7e639f..fef08a8f6 100644 --- a/src/backend_task/migration/mod.rs +++ b/src/backend_task/migration/mod.rs @@ -17,6 +17,7 @@ use crate::context::AppContext; use crate::context::migration_status::MigrationState; pub mod finish_unwire; +pub mod legacy_settings; pub mod single_key_restore; pub use finish_unwire::MigrationError; diff --git a/src/context/migration_status.rs b/src/context/migration_status.rs index 8e03bacbd..f064d0c51 100644 --- a/src/context/migration_status.rs +++ b/src/context/migration_status.rs @@ -22,6 +22,9 @@ use arc_swap::ArcSwap; pub enum MigrationStep { /// Sniffing `data.db` for legacy rows. Detecting, + /// Importing DET-owned rows the wallet drain never touched: scheduled + /// DPNS votes and top-up history. + AppData, /// Copying `single_key_wallet` rows into the upstream `SecretStore`. SingleKey, /// Mirroring legacy shielded rows + cursor into the per-wallet sidecar. diff --git a/src/database/initialization.rs b/src/database/initialization.rs index f7e4b3201..7d1b6ee8d 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -2749,6 +2749,23 @@ mod test { params![vec![0xDDu8; 32], vec![0u8; 100]], ) .unwrap(); + + // A queued DPNS vote and a top-up: the non-wallet rows the unwire + // left behind. The v0.9.0 `scheduled_votes` shape has no `network` + // column, so these also cover the pre-v6 reader path. + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed) + VALUES (?1, 'quantum', 'Lock', 1700000000, 0)", + params![identity_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO top_up (identity_id, top_up_index, amount) + VALUES (?1, 0, 100000)", + params![identity_id], + ) + .unwrap(); } assert_eq!(db.db_schema_version().unwrap(), 5); @@ -2808,6 +2825,34 @@ mod test { ) .unwrap(); assert_eq!(lock_identity, Some(vec![0xBBu8; 32])); + + // The ladder must leave the non-wallet rows readable for the import + // that carries them into the k/v store. A dropped scheduled vote is a + // missed vote window, so this is asserted end-to-end against the real + // post-migration schema rather than a hand-built fixture. + use crate::database::legacy_import::{ + read_app_settings, read_scheduled_votes, read_top_ups, + }; + use dash_sdk::dpp::dashcore::Network; + + let votes = read_scheduled_votes(&conn, Network::Mainnet).unwrap(); + assert_eq!(votes.unreadable, 0); + assert_eq!(votes.votes.len(), 1, "the scheduled vote must survive"); + assert_eq!(votes.votes[0].contested_name, "quantum"); + assert!(!votes.votes[0].executed_successfully); + + let top_ups = read_top_ups(&conn, Network::Mainnet).unwrap(); + assert_eq!(top_ups.len(), 1); + assert_eq!(top_ups[0].1.get(&0), Some(&100_000)); + + let settings = read_app_settings(&conn) + .unwrap() + .expect("the settings row must survive the ladder"); + assert_eq!( + settings.network, + Network::Mainnet, + "the saved network must survive; resetting it relaunches the user elsewhere", + ); } // ── v34 migration: SPV-default backend ────────────────────────── diff --git a/src/database/legacy_import.rs b/src/database/legacy_import.rs new file mode 100644 index 000000000..f408cf2fc --- /dev/null +++ b/src/database/legacy_import.rs @@ -0,0 +1,707 @@ +//! Typed readers for the non-wallet legacy `data.db` tables. +//! +//! The unwire moved user preferences, scheduled DPNS votes and top-up +//! history out of `data.db` into the upstream k/v store, but left the old +//! rows in place. These readers are the read half of the import that +//! carries them across; they decode rows into the modern domain types and +//! report per-row decode failures as counters. Deciding what to do with a +//! failure (retry, surface a banner) is the caller's job — see +//! [`backend_task::migration`](crate::backend_task::migration). +//! +//! Every reader treats a missing table as "no data": a fresh install never +//! creates these tables, and that is not an error. + +use std::collections::BTreeMap; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::Identifier; +use rusqlite::Connection; + +use crate::backend_task::contested_names::ScheduledDPNSVote; +use crate::database::Database; +use crate::model::settings::{ + AppSettings, RootScreenType, UserMode, network_from_legacy_str, theme_mode_from_str, +}; + +/// Legacy spelling of mainnet in `data.db`. Migration 29 rewrites it to +/// `mainnet`, but a DB that never reached v29 still carries the old value, +/// so every network filter accepts both spellings. +const LEGACY_MAINNET_ALIAS: &str = "dash"; + +/// Outcome of one legacy scheduled-vote read. +/// +/// `unreadable` counts rows the reader could not decode into a +/// [`ScheduledDPNSVote`] (corrupt voter id, unparseable vote choice). They +/// are reported rather than silently dropped so the caller can refuse to +/// mark the import complete — a dropped vote is a missed vote window. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct LegacyScheduledVotes { + /// Rows decoded into the modern domain type. + pub votes: Vec, + /// Rows that failed to decode. Never silently ignored by the caller. + pub unreadable: u32, +} + +/// Per-identity top-up history keyed by identity id, mirroring the shape +/// [`AppContext::save_top_ups`](crate::context::AppContext::save_top_ups) +/// persists (`top_up_index -> amount`). +pub(crate) type LegacyTopUps = Vec<([u8; 32], BTreeMap)>; + +/// Read the user preferences held in the legacy `settings` row. +/// +/// Returns `None` when the table or its singleton row is absent (a fresh +/// install). Columns the row does not carry — older schemas predate several +/// of them — fall back to the corresponding [`AppSettings::default`] value, +/// so a partial legacy row still yields a complete settings blob. +pub(crate) fn read_app_settings(conn: &Connection) -> rusqlite::Result> { + if !table_exists(conn, "settings")? { + return Ok(None); + } + + // `None` means "autodetect at load time", which is what a legacy row with + // no explicit path means too. `default()` autodetects eagerly, so clear it + // — otherwise the import would freeze today's detected path into the blob. + let mut settings = AppSettings { + dash_qt_path: None, + ..AppSettings::default() + }; + + // Every column below is probed: the legacy ladder added them one at a + // time, so an old `data.db` legitimately lacks the later ones. A missing + // column keeps the default rather than failing the whole read. + let columns = settings_columns(conn)?; + let fields: Vec<&str> = [ + "network", + "start_root_screen", + "theme_preference", + "onboarding_completed", + "show_evonode_tools", + "user_mode", + "custom_dash_qt_path", + "overwrite_dash_conf", + "disable_zmq", + "auto_start_spv", + "close_dash_qt_on_exit", + ] + .into_iter() + .filter(|col| columns.iter().any(|c| c == col)) + .collect(); + if fields.is_empty() { + return Ok(None); + } + + // Static column names from the list above — no user input reaches this + // `format!`. SQLite cannot bind identifiers, so this is the canonical shape. + let sql = format!("SELECT {} FROM settings WHERE id = 1", fields.join(", ")); + let row = conn.query_row(&sql, [], |row| { + let mut values: BTreeMap<&str, rusqlite::types::Value> = BTreeMap::new(); + for (idx, name) in fields.iter().enumerate() { + values.insert(name, row.get::<_, rusqlite::types::Value>(idx)?); + } + Ok(values) + }); + + let values = match row { + Ok(v) => v, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(e), + }; + + if let Some(s) = value_as_string(&values, "network") + && let Some(network) = network_from_legacy_str(&s) + { + settings.network = network; + } + if let Some(i) = value_as_i64(&values, "start_root_screen") + && let Ok(i) = u32::try_from(i) + && let Some(root) = RootScreenType::from_int(i) + { + settings.root_screen_type = root; + } + if let Some(s) = value_as_string(&values, "theme_preference") { + settings.theme_mode = theme_mode_from_str(&s); + } + if let Some(s) = value_as_string(&values, "user_mode") { + settings.user_mode = UserMode::from_str_or_default(&s); + } + if let Some(b) = value_as_bool(&values, "onboarding_completed") { + settings.onboarding_completed = b; + } + if let Some(b) = value_as_bool(&values, "show_evonode_tools") { + settings.show_evonode_tools = b; + } + if let Some(b) = value_as_bool(&values, "overwrite_dash_conf") { + settings.overwrite_dash_conf = b; + } + if let Some(b) = value_as_bool(&values, "disable_zmq") { + settings.disable_zmq = b; + } + if let Some(b) = value_as_bool(&values, "auto_start_spv") { + settings.auto_start_spv = b; + } + if let Some(b) = value_as_bool(&values, "close_dash_qt_on_exit") { + settings.close_dash_qt_on_exit = b; + } + if let Some(s) = value_as_string(&values, "custom_dash_qt_path") { + settings.dash_qt_path = Some(std::path::PathBuf::from(s)); + } + + Ok(Some(settings)) +} + +/// Read the scheduled DPNS votes queued for `network`. +/// +/// Pre-v6 schemas have no `network` column; those rows predate multi-network +/// support and belong to mainnet, so they are returned only for +/// [`Network::Mainnet`]. +pub(crate) fn read_scheduled_votes( + conn: &Connection, + network: Network, +) -> rusqlite::Result { + if !table_exists(conn, "scheduled_votes")? { + return Ok(LegacyScheduledVotes::default()); + } + + let has_network = column_exists(conn, "scheduled_votes", "network")?; + let base = "SELECT identity_id, contested_name, vote_choice, time, executed \ + FROM scheduled_votes"; + + let mut out = LegacyScheduledVotes::default(); + let mut stmt; + let rows = if has_network { + stmt = conn.prepare(&format!("{base} WHERE network IN (?1, ?2)"))?; + stmt.query(rusqlite::params![ + network.to_string(), + mainnet_alias_for(network) + ])? + } else if network == Network::Mainnet { + stmt = conn.prepare(base)?; + stmt.query([])? + } else { + return Ok(out); + }; + + let mut rows = rows; + while let Some(row) = rows.next()? { + let voter_id: Vec = row.get(0)?; + let contested_name: String = row.get(1)?; + let vote_choice: String = row.get(2)?; + let unix_timestamp: u64 = row.get(3)?; + let executed: i64 = row.get(4)?; + + let Ok(voter_id) = Identifier::from_bytes(&voter_id) else { + tracing::warn!( + target = "database::legacy_import", + contested_name = %contested_name, + "Skipping legacy scheduled vote with an undecodable voter id", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + }; + let Some(choice) = parse_vote_choice(&vote_choice) else { + tracing::warn!( + target = "database::legacy_import", + contested_name = %contested_name, + "Skipping legacy scheduled vote with an unparseable vote choice", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + }; + + out.votes.push(ScheduledDPNSVote { + contested_name, + voter_id, + choice, + unix_timestamp, + executed_successfully: executed != 0, + }); + } + + Ok(out) +} + +/// Read the top-up history of every identity on `network`. +/// +/// The legacy `top_up` table carries no network column, so rows are scoped +/// by joining the legacy `identity` table. An identity whose row is gone +/// contributes nothing — its top-ups are unreachable audit trail. +pub(crate) fn read_top_ups(conn: &Connection, network: Network) -> rusqlite::Result { + if !table_exists(conn, "top_up")? || !table_exists(conn, "identity")? { + return Ok(Vec::new()); + } + + let mut stmt = conn.prepare( + "SELECT t.identity_id, t.top_up_index, t.amount \ + FROM top_up t JOIN identity i ON i.id = t.identity_id \ + WHERE i.network IN (?1, ?2) \ + ORDER BY t.identity_id, t.top_up_index", + )?; + let mut rows = stmt.query(rusqlite::params![ + network.to_string(), + mainnet_alias_for(network) + ])?; + + let mut grouped: BTreeMap<[u8; 32], BTreeMap> = BTreeMap::new(); + while let Some(row) = rows.next()? { + let identity_id: Vec = row.get(0)?; + let index: u32 = row.get(1)?; + let amount: u64 = row.get(2)?; + let Ok(identity_id) = <[u8; 32]>::try_from(identity_id.as_slice()) else { + tracing::warn!( + target = "database::legacy_import", + blob_len = identity_id.len(), + "Skipping legacy top-up row with a non-32-byte identity id", + ); + continue; + }; + grouped + .entry(identity_id) + .or_default() + .insert(index, amount); + } + + Ok(grouped.into_iter().collect()) +} + +/// Decode the legacy `vote_choice` text, which is the `Display` form of +/// [`ResourceVoteChoice`] (`Abstain`, `Lock`, `TowardsIdentity()`). +fn parse_vote_choice(raw: &str) -> Option { + match raw { + "Abstain" => Some(ResourceVoteChoice::Abstain), + "Lock" => Some(ResourceVoteChoice::Lock), + other => { + let inner = other + .strip_prefix("TowardsIdentity(") + .and_then(|s| s.strip_suffix(')'))?; + Identifier::from_string(inner, Encoding::Base58) + .ok() + .map(ResourceVoteChoice::TowardsIdentity) + } + } +} + +/// The network spelling a pre-v29 `data.db` used. Only mainnet was ever +/// renamed, so every other network maps to itself and the two-value `IN` +/// filter stays a single code path. +fn mainnet_alias_for(network: Network) -> String { + match network { + Network::Mainnet => LEGACY_MAINNET_ALIAS.to_string(), + other => other.to_string(), + } +} + +fn table_exists(conn: &Connection, table: &str) -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) +} + +fn column_exists(conn: &Connection, table: &str, column: &str) -> rusqlite::Result { + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2", + rusqlite::params![table, column], + |row| row.get::<_, i64>(0).map(|c| c > 0), + ) +} + +fn settings_columns(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('settings')")?; + let names = stmt.query_map([], |row| row.get::<_, String>(0))?; + names.collect() +} + +fn value_as_string(values: &BTreeMap<&str, rusqlite::types::Value>, key: &str) -> Option { + match values.get(key) { + Some(rusqlite::types::Value::Text(s)) => Some(s.clone()), + _ => None, + } +} + +fn value_as_i64(values: &BTreeMap<&str, rusqlite::types::Value>, key: &str) -> Option { + match values.get(key) { + Some(rusqlite::types::Value::Integer(i)) => Some(*i), + _ => None, + } +} + +fn value_as_bool(values: &BTreeMap<&str, rusqlite::types::Value>, key: &str) -> Option { + value_as_i64(values, key).map(|i| i != 0) +} + +impl Database { + /// Read the legacy `settings` row through the shared connection. + /// See [`read_app_settings`]. + pub(crate) fn read_legacy_app_settings(&self) -> rusqlite::Result> { + read_app_settings(&self.locked_conn()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::settings::ThemeMode; + + /// The v0.10-dev `settings` shape: every user-preference column the + /// ladder ever added, at the post-v29 (`mainnet`) network spelling. + fn create_settings_table(conn: &Connection) { + conn.execute_batch( + "CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + network TEXT NOT NULL, + start_root_screen INTEGER NOT NULL, + custom_dash_qt_path TEXT, + overwrite_dash_conf INTEGER, + theme_preference TEXT, + disable_zmq INTEGER, + onboarding_completed INTEGER, + show_evonode_tools INTEGER, + user_mode TEXT, + auto_start_spv INTEGER, + close_dash_qt_on_exit INTEGER, + database_version INTEGER NOT NULL + );", + ) + .unwrap(); + } + + fn create_scheduled_votes_table(conn: &Connection, with_network: bool) { + let network_col = if with_network { + ", network TEXT NOT NULL" + } else { + "" + }; + conn.execute_batch(&format!( + "CREATE TABLE scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0 + {network_col}, + PRIMARY KEY (identity_id, contested_name) + );" + )) + .unwrap(); + } + + fn insert_vote( + conn: &Connection, + voter: &[u8; 32], + name: &str, + choice: &str, + executed: i64, + network: Option<&str>, + ) { + match network { + Some(net) => conn + .execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, ?2, ?3, 1700000000, ?4, ?5)", + rusqlite::params![voter.as_slice(), name, choice, executed, net], + ) + .unwrap(), + None => conn + .execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed) + VALUES (?1, ?2, ?3, 1700000000, ?4)", + rusqlite::params![voter.as_slice(), name, choice, executed], + ) + .unwrap(), + }; + } + + /// A user on testnet with a customised theme, root screen and toggles + /// keeps every one of them across the upgrade. The network field is the + /// safety-critical one: dropping it relaunches a testnet user on mainnet. + #[test] + fn app_settings_round_trip_every_preference() { + let conn = Connection::open_in_memory().unwrap(); + create_settings_table(&conn); + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, custom_dash_qt_path, + overwrite_dash_conf, theme_preference, disable_zmq, onboarding_completed, + show_evonode_tools, user_mode, auto_start_spv, close_dash_qt_on_exit, + database_version) + VALUES (1, 'testnet', ?1, '/opt/dash-qt', 0, 'Dark', 1, 1, 1, 'Beginner', 0, 0, 40)", + rusqlite::params![RootScreenType::RootScreenDPNSScheduledVotes.to_int()], + ) + .unwrap(); + + let settings = read_app_settings(&conn).unwrap().expect("settings row"); + + assert_eq!(settings.network, Network::Testnet); + assert_eq!( + settings.root_screen_type, + RootScreenType::RootScreenDPNSScheduledVotes + ); + assert_eq!(settings.theme_mode, ThemeMode::Dark); + assert!(settings.onboarding_completed); + assert!(settings.show_evonode_tools); + assert_eq!(settings.user_mode, UserMode::Beginner); + assert!(settings.disable_zmq); + assert!(!settings.overwrite_dash_conf); + assert!(!settings.auto_start_spv); + assert!(!settings.close_dash_qt_on_exit); + assert_eq!( + settings.dash_qt_path, + Some(std::path::PathBuf::from("/opt/dash-qt")) + ); + } + + /// A `data.db` that never reached migration 29 still spells mainnet + /// `dash`. Failing to map it would silently relaunch the user on the + /// default network. + #[test] + fn app_settings_accepts_pre_v29_dash_network_name() { + let conn = Connection::open_in_memory().unwrap(); + create_settings_table(&conn); + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, database_version) + VALUES (1, 'dash', 0, 29)", + [], + ) + .unwrap(); + + let settings = read_app_settings(&conn).unwrap().expect("settings row"); + assert_eq!(settings.network, Network::Mainnet); + } + + /// The v0.9.0 schema has neither `theme_preference` nor the onboarding + /// columns. Absent columns must fall back to defaults, not fail the read. + #[test] + fn app_settings_missing_columns_fall_back_to_defaults() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + network TEXT NOT NULL, + start_root_screen INTEGER NOT NULL, + database_version INTEGER NOT NULL + );", + ) + .unwrap(); + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, database_version) + VALUES (1, 'testnet', 0, 5)", + [], + ) + .unwrap(); + + let settings = read_app_settings(&conn).unwrap().expect("settings row"); + let defaults = AppSettings::default(); + assert_eq!(settings.network, Network::Testnet, "present column wins"); + assert_eq!(settings.theme_mode, defaults.theme_mode); + assert_eq!(settings.onboarding_completed, defaults.onboarding_completed); + } + + /// A legacy row with no explicit Dash-Qt path means "autodetect", so the + /// import must leave the field empty rather than freezing today's detected + /// path into storage — otherwise moving Dash-Qt later breaks the link. + #[test] + fn app_settings_null_dash_qt_path_stays_autodetect() { + let conn = Connection::open_in_memory().unwrap(); + create_settings_table(&conn); + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, custom_dash_qt_path, + database_version) + VALUES (1, 'testnet', 0, NULL, 40)", + [], + ) + .unwrap(); + + let settings = read_app_settings(&conn).unwrap().expect("settings row"); + assert_eq!(settings.dash_qt_path, None); + } + + /// A fresh install has no `settings` table at all. + #[test] + fn app_settings_absent_table_reads_none() { + let conn = Connection::open_in_memory().unwrap(); + assert!(read_app_settings(&conn).unwrap().is_none()); + } + + /// An empty `settings` table (table created, row never written) is also + /// "no legacy settings" — not an error. + #[test] + fn app_settings_absent_row_reads_none() { + let conn = Connection::open_in_memory().unwrap(); + create_settings_table(&conn); + assert!(read_app_settings(&conn).unwrap().is_none()); + } + + /// Every vote choice the UI can queue must survive the import, including + /// the executed flag — a re-imported vote that lost `executed` would be + /// cast twice. + #[test] + fn scheduled_votes_decode_all_choice_kinds() { + let conn = Connection::open_in_memory().unwrap(); + create_scheduled_votes_table(&conn, true); + let voter = [0x11u8; 32]; + let towards = Identifier::from([0x22u8; 32]); + + insert_vote(&conn, &voter, "alice", "Abstain", 0, Some("testnet")); + insert_vote(&conn, &voter, "bob", "Lock", 1, Some("testnet")); + insert_vote( + &conn, + &voter, + "carol", + &format!("TowardsIdentity({})", towards.to_string(Encoding::Base58)), + 0, + Some("testnet"), + ); + + let read = read_scheduled_votes(&conn, Network::Testnet).unwrap(); + + assert_eq!(read.unreadable, 0); + assert_eq!(read.votes.len(), 3); + let by_name = |name: &str| { + read.votes + .iter() + .find(|v| v.contested_name == name) + .unwrap_or_else(|| panic!("vote {name} missing")) + }; + assert_eq!(by_name("alice").choice, ResourceVoteChoice::Abstain); + assert_eq!(by_name("bob").choice, ResourceVoteChoice::Lock); + assert!(by_name("bob").executed_successfully); + assert!(!by_name("alice").executed_successfully); + assert_eq!( + by_name("carol").choice, + ResourceVoteChoice::TowardsIdentity(towards) + ); + assert_eq!(by_name("alice").voter_id, Identifier::from(voter)); + assert_eq!(by_name("alice").unix_timestamp, 1_700_000_000); + } + + /// Votes queued on another network must not leak into this network's + /// import — casting a mainnet vote from a testnet queue is a real error. + #[test] + fn scheduled_votes_filter_by_network() { + let conn = Connection::open_in_memory().unwrap(); + create_scheduled_votes_table(&conn, true); + let voter = [0x11u8; 32]; + insert_vote(&conn, &voter, "on-testnet", "Lock", 0, Some("testnet")); + insert_vote(&conn, &voter, "on-mainnet", "Lock", 0, Some("mainnet")); + + let testnet = read_scheduled_votes(&conn, Network::Testnet).unwrap(); + assert_eq!(testnet.votes.len(), 1); + assert_eq!(testnet.votes[0].contested_name, "on-testnet"); + + let mainnet = read_scheduled_votes(&conn, Network::Mainnet).unwrap(); + assert_eq!(mainnet.votes.len(), 1); + assert_eq!(mainnet.votes[0].contested_name, "on-mainnet"); + } + + /// Pre-v29 mainnet rows spell the network `dash`; they belong to the + /// mainnet queue. + #[test] + fn scheduled_votes_accept_pre_v29_dash_rows() { + let conn = Connection::open_in_memory().unwrap(); + create_scheduled_votes_table(&conn, true); + insert_vote(&conn, &[0x11u8; 32], "legacy", "Lock", 0, Some("dash")); + + let read = read_scheduled_votes(&conn, Network::Mainnet).unwrap(); + assert_eq!(read.votes.len(), 1); + assert!( + read_scheduled_votes(&conn, Network::Testnet) + .unwrap() + .votes + .is_empty(), + "a pre-v29 mainnet row must not surface on testnet", + ); + } + + /// The v0.9.0 `scheduled_votes` shape predates multi-network support and + /// has no `network` column; those rows are mainnet's. + #[test] + fn scheduled_votes_without_network_column_belong_to_mainnet() { + let conn = Connection::open_in_memory().unwrap(); + create_scheduled_votes_table(&conn, false); + insert_vote(&conn, &[0x11u8; 32], "pre-v6", "Abstain", 0, None); + + let mainnet = read_scheduled_votes(&conn, Network::Mainnet).unwrap(); + assert_eq!(mainnet.votes.len(), 1); + assert_eq!(mainnet.votes[0].contested_name, "pre-v6"); + + let testnet = read_scheduled_votes(&conn, Network::Testnet).unwrap(); + assert!(testnet.votes.is_empty()); + } + + /// A corrupt row is counted, never silently dropped, and never blocks the + /// readable votes around it. + #[test] + fn scheduled_votes_count_unreadable_rows() { + let conn = Connection::open_in_memory().unwrap(); + create_scheduled_votes_table(&conn, true); + let voter = [0x11u8; 32]; + insert_vote(&conn, &voter, "good", "Lock", 0, Some("testnet")); + insert_vote(&conn, &voter, "bad-choice", "Nonsense", 0, Some("testnet")); + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, 'bad-voter', 'Lock', 1, 0, 'testnet')", + rusqlite::params![vec![0xFFu8; 5]], + ) + .unwrap(); + + let read = read_scheduled_votes(&conn, Network::Testnet).unwrap(); + assert_eq!(read.votes.len(), 1, "the readable vote still imports"); + assert_eq!(read.votes[0].contested_name, "good"); + assert_eq!(read.unreadable, 2, "both corrupt rows are reported"); + } + + #[test] + fn scheduled_votes_absent_table_reads_empty() { + let conn = Connection::open_in_memory().unwrap(); + let read = read_scheduled_votes(&conn, Network::Testnet).unwrap(); + assert_eq!(read, LegacyScheduledVotes::default()); + } + + /// Top-ups group per identity and stay scoped to the identity's network. + #[test] + fn top_ups_group_per_identity_and_scope_by_network() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE identity (id BLOB PRIMARY KEY, network TEXT NOT NULL); + CREATE TABLE top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (identity_id, top_up_index) + );", + ) + .unwrap(); + let mine = [0xAAu8; 32]; + let other_net = [0xBBu8; 32]; + for (id, net) in [(mine, "testnet"), (other_net, "mainnet")] { + conn.execute( + "INSERT INTO identity (id, network) VALUES (?1, ?2)", + rusqlite::params![id.as_slice(), net], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO top_up (identity_id, top_up_index, amount) VALUES + (?1, 0, 1000), (?1, 1, 2000), (?2, 0, 9999)", + rusqlite::params![mine.as_slice(), other_net.as_slice()], + ) + .unwrap(); + + let top_ups = read_top_ups(&conn, Network::Testnet).unwrap(); + + assert_eq!(top_ups.len(), 1, "only the testnet identity's top-ups"); + assert_eq!(top_ups[0].0, mine); + assert_eq!(top_ups[0].1, BTreeMap::from([(0, 1000), (1, 2000)])); + } + + #[test] + fn top_ups_absent_table_reads_empty() { + let conn = Connection::open_in_memory().unwrap(); + assert!(read_top_ups(&conn, Network::Testnet).unwrap().is_empty()); + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index ca9961d22..a6b164f32 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,4 +1,5 @@ mod initialization; +pub(crate) mod legacy_import; mod settings; mod single_key_wallet; #[cfg(any(test, feature = "testing"))] diff --git a/src/model/settings.rs b/src/model/settings.rs index 5a00dc425..b6f5bc14f 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -34,7 +34,7 @@ impl UserMode { } } - fn from_str_or_default(s: &str) -> Self { + pub(crate) fn from_str_or_default(s: &str) -> Self { match s { "Beginner" => UserMode::Beginner, _ => UserMode::Advanced, @@ -325,10 +325,7 @@ impl From<&AppSettings> for AppSettingsWire { impl From for AppSettings { fn from(w: AppSettingsWire) -> Self { let defaults = AppSettings::default(); - let network = match w.network.to_lowercase().as_str() { - "dash" => Network::Mainnet, - other => Network::from_str(other).unwrap_or(defaults.network), - }; + let network = network_from_legacy_str(&w.network).unwrap_or(defaults.network); let root_screen_type = RootScreenType::from_int(w.root_screen_type).unwrap_or(defaults.root_screen_type); let theme_mode = theme_mode_from_str(&w.theme_mode); @@ -372,7 +369,7 @@ fn theme_mode_to_str(mode: ThemeMode) -> &'static str { } } -fn theme_mode_from_str(s: &str) -> ThemeMode { +pub(crate) fn theme_mode_from_str(s: &str) -> ThemeMode { match s { "Light" => ThemeMode::Light, "Dark" => ThemeMode::Dark, @@ -380,6 +377,20 @@ fn theme_mode_from_str(s: &str) -> ThemeMode { } } +/// Parse a network name as written by DET, accepting the pre-v29 spelling. +/// +/// `data.db` (and therefore every stored settings blob) wrote mainnet as +/// `dash` until migration 29 renamed it to `mainnet`. Both spellings must +/// resolve, or an upgrading user silently lands on the default network. +/// Returns `None` for an unrecognised name so the caller can keep its own +/// fallback. +pub(crate) fn network_from_legacy_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "dash" => Some(Network::Mainnet), + other => Network::from_str(other).ok(), + } +} + /// Detects the path to the Dash-Qt binary on the system. /// /// Filesystem IO — never call from a `Deserialize` path. Callers that need an diff --git a/tests/kittest/migration_banner.rs b/tests/kittest/migration_banner.rs index ff6c444c5..3e34c2521 100644 --- a/tests/kittest/migration_banner.rs +++ b/tests/kittest/migration_banner.rs @@ -41,6 +41,7 @@ fn tc_mig_001_running_banner_shows_step_label() { fn tc_mig_014_running_text_covers_every_step_with_sentence() { for step in [ MigrationStep::Detecting, + MigrationStep::AppData, MigrationStep::SingleKey, MigrationStep::Shielded, MigrationStep::WalletSeeds, From 025cf27c920337a28e451db45e58ccb3915d31b2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:28:47 +0000 Subject: [PATCH 09/30] fix(wallets): remove dead RPC-mode gate on single-key send, surface limitation in-app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/user-stories.md | 11 +- src/backend_task/core/mod.rs | 57 ++++--- src/ui/wallets/single_key_send_screen.rs | 43 +++-- src/ui/wallets/wallets_screen/mod.rs | 12 +- .../wallets/wallets_screen/single_key_view.rs | 148 ++++++++++++------ 5 files changed, 169 insertions(+), 102 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index af4904cfb..931406b40 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -279,13 +279,13 @@ As a developer, I want to see the individual notes in my shielded pool — their - The Shielded tab lists each note with value, block height, and spent/unspent state, plus a synced-index and note-count summary. - Currently a gap: the Shielded Notes section renders only a placeholder ("Note history is managed by the upstream platform-wallet coordinator and will be surfaced here in a future update") — no per-note table, status, or count is shown. -### WAL-030: Refresh a single-key wallet's balance and UTXOs [Gap] +### WAL-030: Single-key wallet balance and UTXOs update automatically [Gap] **Persona:** Priya, Jordan -As a user with an imported single-key wallet, I want to refresh its balance and UTXO list so that I can see funds that arrived at the imported address. +As a user with an imported single-key wallet, I want its balance and UTXO list to update on their own as funds arrive and are spent, so that I can see my funds without hunting for a refresh control. -- A refresh action updates the single-key wallet's balance and UTXO set from the chain. -- Currently a gap (Decision #7): `RefreshSingleKeyWalletInfo` returns a "not supported in this version" result; single-key balance/UTXO refresh is re-enabled when single-key moves onto the upstream wallet runtime that provides UTXO discovery. Key data and receive still work. +- The imported address is monitored automatically, the same way recovery-phrase wallet addresses are. No manual refresh action is offered. +- Currently blocked upstream: monitoring requires registering the imported address as a watch-only wallet, but `platform-wallet` exposes no seedless wallet-registration entry point (`register_wallet` is private; the public constructors all require a recovery-phrase seed). Unblocked by a public `register_watch_only_wallet`. Key data and receive still work. --- @@ -304,7 +304,8 @@ As a user, I want to send Dash to a recipient address so that I can make payment As a user with an imported private key, I want to send Dash from that single-key wallet so that I can move funds to another address. -- Temporarily unavailable in this version (Decision #7): single-key send returns a clear, calm "not supported in this version — your data is preserved; use an HD recovery-phrase wallet" message. Single-key wallet data and its UTXOs are retained on disk and load correctly; only the spend action is gated. Re-enabled when single-key moves onto the upstream wallet runtime. +- Temporarily unavailable in this version: the Send control for a single-key wallet is disabled and the app states the limitation and the workaround in place ("You can still receive funds at this address. To send these funds, import them into a recovery-phrase wallet."). A send that reaches the backend is refused with a typed error carrying the same message. +- Currently blocked upstream, on the same gap as WAL-030: signing and raw-transaction broadcast are both already available, but coin selection needs the imported address's UTXOs, which cannot be discovered until the address can be registered as a watch-only wallet. Single-key wallet data is retained on disk and loads correctly; only the spend action is gated. ### SND-003: Receive Dash with QR code [Implemented] **Persona:** Alex, Priya diff --git a/src/backend_task/core/mod.rs b/src/backend_task/core/mod.rs index 89b7455df..0ac9db1c8 100644 --- a/src/backend_task/core/mod.rs +++ b/src/backend_task/core/mod.rs @@ -185,17 +185,28 @@ impl AppContext { }; Ok(BackendTaskSuccessResult::RefreshedWallet { warning }) } - // Single-key send/refresh unsupported this release — by design (single-key-mock.md, Decision #7). - // TODO: implementing balance/UTXO refresh for a bare imported P2PKH key - // needs UTXO discovery, which has no DET-local path. Per the F1 spike it - // requires (a) a key-wallet single-address pool/account helper (e.g. - // `AddressPool::with_single_address`) and (b) a public platform-wallet - // constructor `PlatformWalletManager::register_watch_only_wallet` that - // runs the existing private `register_wallet` body — both parked on - // an upstream platform-wallet change. Once those land, register the key - // as a degenerate watch-only wallet keyed by - // `seed_hash = SHA-256(SINGLE_KEY_NAMESPACE_BYTES ‖ addr)` and project - // `wallet_balance`/`utxos` into the `SingleKeyWallet` display fields. + // Single-key balance/UTXO monitoring is blocked on upstream platform-wallet. + // + // The SPV watch set is the union of every managed account's address-pool + // addresses (`ManagedWalletInfo::monitored_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 already + // has everything needed to build that pool without derivation + // (`AddressPool::new_without_generation` + `AddressInfo` + `KeySource::NoKeySource`). + // + // TODO: what is missing is a way to REGISTER such a wallet. platform-wallet's + // `PlatformWalletManager::register_wallet` is private, and the only public + // registration entry points (`create_wallet_from_mnemonic` / + // `create_wallet_from_seed_bytes`) require an HD seed. 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 entry point upstream, e.g. + // `register_watch_only_wallet(wallet, info, birth_height)` running the + // existing private `register_wallet` body. Once that lands, register the key + // as a watch-only wallet whose external pool holds exactly the imported + // address, and monitoring becomes automatic — no refresh action, so this + // task should be deleted rather than implemented. CoreTask::RefreshSingleKeyWalletInfo(_wallet) => { Err(TaskError::SingleKeyWalletsUnsupported) } @@ -278,19 +289,17 @@ impl AppContext { total_amount, }) } - // Single-key send/refresh unsupported this release — by design (single-key-mock.md, Decision #7). - // TODO: raw-tx broadcast is available upstream (`SpvBroadcaster`) and - // F1-independent. What is - // missing is coin selection over the imported key's UTXOs, which depends - // on the same UTXO-discovery upstream change as the refresh path above - // (the key-wallet single-address pool helper + the platform-wallet - // `register_watch_only_wallet` constructor). Once UTXOs are discoverable, - // build a P2PKH tx from `utxos(seed_hash)`, sign via `DetSigner::SingleKey`, - // and broadcast. The related UI re-point (drop the dead `is_rpc_mode` gating - // in `single_key_send_screen.rs`, route fee math through - // `model/fee_estimation.rs`, and replace the string-parsed min-relay error - // with a typed `TaskError` variant) lands with this. Do NOT touch the parked - // `single_key_send_screen.rs` fee math. + // Single-key send is blocked on the same upstream gap as the refresh path + // above: without a registered watch-only wallet there is no UTXO set to + // select coins from. Raw-tx broadcast (`SpvBroadcaster`) and signing + // (`DetSigner::SingleKey`, via the `secret_seam` chokepoint) are both already + // available — coin selection is the only missing input. + // + // TODO: once the imported address is registered and its UTXOs are + // discoverable, build a P2PKH tx from `utxos(seed_hash)`, sign through the + // secret seam, and broadcast. Landing that also re-points the parked send + // UI: route its fee math through `model/fee_estimation.rs` and replace the + // string-parsed min-relay error with a typed `TaskError` variant. CoreTask::SendSingleKeyWalletPayment { wallet: _, request: _, diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 0aad9fb5c..58cc98381 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -778,15 +778,19 @@ impl SingleKeyWalletSendScreen { ui.add_space(20.0); - // Send button + // Send button. Enabled purely by the local preconditions (wallet + // unlocked, no send in flight) — the version limitation is not a UI + // gate: `CoreTask::SendSingleKeyWalletPayment` is the authoritative + // enforcement layer and refuses with a typed + // `TaskError::SingleKeyWalletsUnsupported`, which surfaces as an + // error banner. The warning banner above states the limitation + // before the user commits to the attempt. let wallet_is_open = self .selected_wallet .as_ref() .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - // Single-key wallets are unsupported in this version. - let is_rpc_mode = false; - let button_enabled = wallet_is_open && !self.sending && is_rpc_mode; + let button_enabled = wallet_is_open && !self.sending; // Only force white label text when the button is actually clickable; // otherwise let egui's default disabled visuals take over so the // greyed-out state is visually unambiguous. @@ -805,10 +809,7 @@ impl SingleKeyWalletSendScreen { }) .min_size(egui::vec2(120.0, 36.0)); - let mut response = ui.add_enabled(button_enabled, send_button); - if !is_rpc_mode { - response = response.on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE); - } + let response = ui.add_enabled(button_enabled, send_button); if response.clicked() { match self.validate_and_send() { Ok(send_action) => { @@ -842,9 +843,6 @@ impl ScreenLike for SingleKeyWalletSendScreen { RootScreenType::RootScreenWalletsBalances, ); - // Single-key wallets are unsupported in this version. - let is_rpc_mode = false; - action |= island_central_panel(ui, |ui| { let mut inner_action = AppAction::None; let dark_mode = ui.style().visuals.dark_mode; @@ -854,21 +852,18 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { - // Persistent warning banner for the SPV backend. Stored on + // States the version limitation up front, so a send attempt + // that the backend will refuse is never a surprise. Stored on // the screen so the underlying tracing log fires once on - // mode entry instead of every repaint — see the matching - // note in `single_key_view.rs`. - if !is_rpc_mode { - if !self.spv_warning_banner.has_message() { - self.spv_warning_banner - .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) - .disable_auto_dismiss(); - } - self.spv_warning_banner.show(ui); - ui.add_space(10.0); - } else if self.spv_warning_banner.has_message() { - self.spv_warning_banner.clear(); + // entry instead of every repaint — see the matching note in + // `single_key_view.rs`. + if !self.spv_warning_banner.has_message() { + self.spv_warning_banner + .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) + .disable_auto_dismiss(); } + self.spv_warning_banner.show(ui); + ui.add_space(10.0); // Heading with Advanced Options checkbox ui.horizontal(|ui| { diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 2540cc712..23a7e3f04 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -1121,10 +1121,14 @@ impl WalletsBalancesScreen { ) .create_screen(&self.app_context), ); - } else if let Some(sk_wallet) = &self.selected_single_key_wallet { - action = AppAction::AddScreen( - crate::ui::ScreenType::SingleKeyWalletSendScreen(sk_wallet.clone()) - .create_screen(&self.app_context), + } else if self.selected_single_key_wallet.is_some() { + // Single-key send cannot work in this version, so state the + // limitation here rather than routing the user into a send + // screen that could only refuse the payment. + MessageBanner::set_global( + ui.ctx(), + SINGLE_KEY_SEND_UNAVAILABLE, + MessageType::Warning, ); } else { MessageBanner::set_global( diff --git a/src/ui/wallets/wallets_screen/single_key_view.rs b/src/ui/wallets/wallets_screen/single_key_view.rs index 670b133de..110b3cc44 100644 --- a/src/ui/wallets/wallets_screen/single_key_view.rs +++ b/src/ui/wallets/wallets_screen/single_key_view.rs @@ -1,7 +1,7 @@ use crate::app::AppAction; +use crate::ui::MessageType; use crate::ui::components::component_trait::Component; use crate::ui::theme::DashColors; -use crate::ui::{MessageType, ScreenType}; use crate::wallet_backend::poison::RwLockRecover; use eframe::egui; use egui::{Frame, Margin, RichText, Ui}; @@ -9,9 +9,9 @@ use egui::{Frame, Margin, RichText, Ui}; use super::WalletsBalancesScreen; /// Shown as a disabled-button tooltip and in the in-screen warning banner for -/// single-key-wallet send actions. Exported so the dedicated send screen can -/// reuse the same copy. Sending from a single-key wallet is not available in -/// this version; receiving still works. +/// single-key-wallet send actions. Exported so the dedicated send screen and +/// the wallets action bar can reuse the same copy. Sending from a single-key +/// wallet is not available in this version; receiving still works. pub(crate) const SINGLE_KEY_SEND_UNAVAILABLE: &str = "Sending from a single-key wallet is not available in this version. You can still receive funds at this address. To send these funds, import them into a recovery-phrase wallet."; impl WalletsBalancesScreen { @@ -21,7 +21,7 @@ impl WalletsBalancesScreen { ui: &mut Ui, dark_mode: bool, ) -> AppAction { - let mut action = AppAction::None; + let action = AppAction::None; let wallet_arc = match &self.selected_single_key_wallet { Some(w) => w.clone(), @@ -41,8 +41,6 @@ impl WalletsBalancesScreen { drop(wallet); let text_color = DashColors::text_primary(dark_mode); - // Single-key wallets are unsupported in this version. - let is_rpc_mode = false; Frame::group(ui.style()) .fill(DashColors::surface(dark_mode)) @@ -56,9 +54,11 @@ impl WalletsBalancesScreen { ui.label(RichText::new(format!("Balance: {:.8} DASH", balance_dash))); ui.add_space(10.0); - // Single-key sending is unavailable this release. The Send - // button below is greyed out; this banner is the "why" the - // user would otherwise miss from a silent disable. + // Sending from a single-key wallet cannot work in this + // version, so the Send button below is permanently + // disabled. This banner carries the "why" plus the + // recovery-phrase workaround that a bare greyed-out + // button would leave unexplained. // // The banner lives on the screen struct so its state is // constructed once and then re-rendered each frame. Setting @@ -66,46 +66,25 @@ impl WalletsBalancesScreen { // local) means `BannerState::logged` is preserved, so the // underlying tracing log fires once — not 60 times a second // while the screen is visible. - if !is_rpc_mode { - if !self.sk_spv_warning_banner.has_message() { - self.sk_spv_warning_banner - .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) - .disable_auto_dismiss(); - } - self.sk_spv_warning_banner.show(ui); - ui.add_space(10.0); - } else if self.sk_spv_warning_banner.has_message() { - self.sk_spv_warning_banner.clear(); + if !self.sk_spv_warning_banner.has_message() { + self.sk_spv_warning_banner + .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) + .disable_auto_dismiss(); } + self.sk_spv_warning_banner.show(ui); + ui.add_space(10.0); // Action buttons for SK wallet ui.horizontal(|ui| { - // Only force the primary text color when the button is - // enabled; otherwise let egui apply its default disabled - // visuals so the button actually looks greyed out. - let send_label = RichText::new("Send").strong(); - let send_label = if is_rpc_mode { - send_label.color(text_color) - } else { - send_label - }; - let send_button = egui::Button::new(send_label); - let send_response = ui.add_enabled(is_rpc_mode, send_button); - let send_response = if is_rpc_mode { - send_response - } else { - send_response.on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE) - }; - if send_response.clicked() { - action = AppAction::AddScreen( - ScreenType::SingleKeyWalletSendScreen(wallet_arc.clone()) - .create_screen(&self.app_context), - ); - } + // Left unstyled so egui's default disabled visuals apply + // and the button reads as genuinely greyed out. + let send_button = egui::Button::new(RichText::new("Send").strong()); + ui.add_enabled(false, send_button) + .on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE); - // Receive only displays the local address — it does - // not touch Core or SPV, so it stays enabled in both - // modes. + // Receive only displays the local address — it needs + // neither UTXO discovery nor signing, so it stays + // available. if ui .button(RichText::new("Receive").color(text_color)) .clicked() @@ -232,3 +211,82 @@ impl WalletsBalancesScreen { action } } + +#[cfg(test)] +mod tests { + use super::SINGLE_KEY_SEND_UNAVAILABLE; + use crate::backend_task::error::TaskError; + + /// Terms the Everyday User persona must never be shown (CLAUDE.md + /// "Error messages" rule 1). "RPC" is listed even though this build is + /// SPV-only: the concept is meaningless to the user either way. + const JARGON: &[&str] = &[ + "SPV", + "RPC", + "UTXO", + "backend", + "consensus", + "nonce", + "SDK", + "state transition", + ]; + + fn assert_everyday_user_copy(msg: &str) { + let lower = msg.to_lowercase(); + for term in JARGON { + assert!( + !lower.contains(&term.to_lowercase()), + "user-facing copy must not contain the jargon term {term:?}: {msg}" + ); + } + // Rule 2: users must be able to self-resolve — never redirected to a + // human. Rule 3: calm, not apologetic/alarming. + assert!( + !lower.contains("contact support"), + "user-facing copy must never redirect to support: {msg}" + ); + assert!( + !lower.contains("sorry") && !lower.contains("went wrong"), + "user-facing copy must stay calm and non-apologetic: {msg}" + ); + } + + /// The single-key send limitation is surfaced in-app, and the copy tells + /// the user what happened AND the concrete step they can take themselves + /// (move the funds into a recovery-phrase wallet). This is the whole + /// user-visible contract of the disabled Send control: without the + /// workaround the message would be a dead end. + #[test] + fn send_unavailable_copy_states_limitation_and_a_self_serve_action() { + assert_everyday_user_copy(SINGLE_KEY_SEND_UNAVAILABLE); + + let lower = SINGLE_KEY_SEND_UNAVAILABLE.to_lowercase(); + assert!( + lower.contains("not available"), + "copy must state the limitation: {SINGLE_KEY_SEND_UNAVAILABLE}" + ); + assert!( + lower.contains("recovery-phrase"), + "copy must name the recovery-phrase workaround so the user can act: \ + {SINGLE_KEY_SEND_UNAVAILABLE}" + ); + assert!( + lower.contains("receive"), + "copy must say receiving still works, so the address is not read as dead: \ + {SINGLE_KEY_SEND_UNAVAILABLE}" + ); + } + + /// The backend is the authoritative enforcement layer: a send that reaches + /// it is refused with a typed variant whose `Display` is itself + /// Everyday-User copy, since it is rendered straight into a `MessageBanner`. + #[test] + fn unsupported_task_error_display_is_everyday_user_copy() { + let msg = TaskError::SingleKeyWalletsUnsupported.to_string(); + assert_everyday_user_copy(&msg); + assert!( + msg.to_lowercase().contains("recovery-phrase"), + "the typed refusal must point at the same workaround as the UI copy: {msg}" + ); + } +} From 361b260116a515fe7e4bcfd3827ebf842cf2db47 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:29:19 +0000 Subject: [PATCH 10/30] feat(nav): wire the masternode and Wallets global-nav pills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/user-stories.md | 5 +- src/ui/components/README.md | 5 +- src/ui/components/global_nav_switcher.rs | 117 +++++++++++++--- src/ui/components/top_panel.rs | 49 ++++--- src/ui/masternodes/list_screen.rs | 128 +++++++++++++++-- src/ui/state/global_nav.rs | 12 +- src/ui/state/masternodes_view.rs | 142 +++++++++++++++---- src/ui/wallets/wallets_screen/mod.rs | 170 ++++++++++++++++++++++- tests/kittest/global_nav_switcher.rs | 56 +++++++- 9 files changed, 595 insertions(+), 89 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index af4904cfb..e4feead37 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1483,10 +1483,11 @@ As a masternode operator, I want a Refresh control on the Masternodes tab, so th - The card-list toolbar and a node's detail view each expose a Refresh action that re-reads the local cache immediately and dispatches a network re-fetch — one identity refresh per loaded node (or the single open node on the detail view) plus a DPNS-contest re-query so vote counts update too. - Refresh is a no-op when no node is loaded, and the detail-view re-query is skipped for a node that has no voter identity. -### MN-012: Switch wallet/identity from the Masternodes header [Gap] +### MN-012: Switch wallet/identity from the Masternodes header [Implemented] **Persona:** Priya As a masternode operator, I want the same page-scoped switcher on the Masternodes header as on other tabs, so that I can see and change the active wallet and the node in view without leaving the page. - The Masternodes header renders the page-aware breadcrumb with an interactive wallet pill (the funding source for Top Up), which two-way binds with the page's wallet context. -- **Gap:** the page-scoped masternode object pill (an interactive `Ⓜ node ▾` dropdown, two-way bound with the card grid and detail view) required by the global-nav design is not built — only the wallet pill is present. Node selection currently works via card-click → detail and the "‹ All masternodes" back link. This is a deferred slice of the cross-cutting global-navigation app-shell work, tracked separately from the Masternodes page. +- The third segment is a page-scoped node pill listing every loaded masternode/evonode, two-way bound with the page: opening a card names that node on the pill, and picking a node from the pill opens its detail view. It reads `(no masternode yet)` when none is loaded and `(choose a masternode)` while the grid is open. +- Picking a node there never changes the identity shown on the everyday-user pages (see MN-005's Identity Hub filter) — the node selection is page-scoped, never the app-global identity. diff --git a/src/ui/components/README.md b/src/ui/components/README.md index 7e51af22c..22c8b173c 100644 --- a/src/ui/components/README.md +++ b/src/ui/components/README.md @@ -23,7 +23,7 @@ Concise catalog of all reusable UI components. Consult before creating new UI el | Component | File | DomainType | Description | |-----------|------|------------|-------------| | `BreadcrumbPill` | `breadcrumb_pill.rs` | `String` | Label + optional icon + chevron. Three modes: Interactive / Subdued / Placeholder. Reusable anywhere a breadcrumb pill is needed (Identities hub breadcrumb, future wallet breadcrumbs). | -| `global_nav_switcher::render()` | `global_nav_switcher.rs` | `GlobalNavEffect` | Page-aware three-segment switcher (`segment-1 › 💼 wallet › 👤 identity/object`) via `top_panel::add_top_panel_with_global_nav` / the Hub's own `breadcrumb_switcher` shim. Live on Identities, DashPay, DPNS, Wallets, Identity Hub, and Masternodes — interactive on Hub/Masternodes, subdued (read-only) on the other four; remaining root screens (Contracts, Tokens, Tools, Network Chooser, Withdraws, …) still render the plain breadcrumb (FR-GLOBAL-NAV rollout in progress). Composes per page from a `PageNavSpec` (`ui/state/global_nav.rs`) and reuses `BreadcrumbPill`/`IdentityPill`. | +| `global_nav_switcher::render()` | `global_nav_switcher.rs` | `GlobalNavEffect` | Page-aware three-segment switcher (`segment-1 › 💼 wallet › 👤 identity/object`) via `top_panel::add_top_panel_with_global_nav[_capturing]` / the Hub's own `breadcrumb_switcher` shim. Live on Identities, DashPay, DPNS, Wallets, Identity Hub, and Masternodes — interactive on Hub, Wallets and Masternodes (Masternodes also carries a page-scoped node pill), subdued (read-only) on Identities, DashPay and DPNS; remaining root screens (Contracts, Tokens, Tools, Network Chooser, Withdraws, …) still render the plain breadcrumb (FR-GLOBAL-NAV rollout in progress). Composes per page from a `PageNavSpec` (`ui/state/global_nav.rs`) and reuses `BreadcrumbPill`/`IdentityPill`. | ## Display Components @@ -77,7 +77,8 @@ directory. |-----------------|------|-------------| | `island_central_panel()` | `styled.rs` | Responsive central panel, renders global MessageBanners | | `add_location_view()` | `top_panel.rs` | Breadcrumb navigation + connection status | -| `add_top_panel_with_global_nav()` | `top_panel.rs` | Top panel wired to `global_nav_switcher::render()` with subdued pills (`subdued_everyday_spec` / `subdued_wallet_only_spec`); used by Identities, DashPay, DPNS, and Wallets. Masternodes uses the identity-aware capturing variant with interactive pills instead. Every other root screen still calls the plain `add_top_panel()` | +| `add_top_panel_with_global_nav()` | `top_panel.rs` | Top panel wired to `global_nav_switcher::render()` for a page that only *reads* the selection — subdued pills (`subdued_everyday_spec` / `subdued_wallet_only_spec`); used by Identities, DashPay and DPNS. Every root screen not listed here still calls the plain `add_top_panel()` | +| `add_top_panel_with_global_nav_capturing()` | `top_panel.rs` | Same, but returns the `GlobalNavEffect` so a page that *consumes* a selection can mirror it into its own state (two-way binding). Used by Wallets (`interactive_wallet_only_spec` → mirrors `SwitchWallet`) and Masternodes (`masternodes_page_nav_spec` → mirrors `SelectPageObject` by opening that node) | | `add_left_panel()` | `left_panel.rs` | Main icon navigation sidebar | | `load_icon()` / `load_svg_icon()` | `icons.rs` | Load & cache embedded raster/SVG icons from `icons/` | | Subscreen panels | `*_subscreen_chooser_panel.rs` | Tab navigation for DPNS, DashPay, Tokens, Tools | diff --git a/src/ui/components/global_nav_switcher.rs b/src/ui/components/global_nav_switcher.rs index 3ffc270f6..0eb4665b3 100644 --- a/src/ui/components/global_nav_switcher.rs +++ b/src/ui/components/global_nav_switcher.rs @@ -139,20 +139,38 @@ fn monogram_initial(label: &str) -> Option { .map(|c| c.to_ascii_uppercase()) } +/// The selected page-scoped item, if the selection resolves against `items`. A +/// stale selection (not in `items`) resolves to `None`, so the pill falls back +/// to its placeholder. +fn resolve_page_object( + items: &[PageObjectItem], + selected: Option, +) -> Option<&PageObjectItem> { + selected.and_then(|id| items.iter().find(|it| it.id == id)) +} + /// The pill label for a page-scoped object: the selected item's label, or the -/// placeholder when nothing is selected (or the selection is stale — not in -/// `items`). +/// placeholder when nothing is selected (or the selection is stale). fn page_object_label( placeholder: &str, items: &[PageObjectItem], selected: Option, ) -> String { - selected - .and_then(|id| items.iter().find(|it| it.id == id)) + resolve_page_object(items, selected) .map(|it| it.label.clone()) .unwrap_or_else(|| placeholder.to_string()) } +/// A page-scoped pill with no items to offer is a placeholder — there is +/// nothing to pick, so it never opens a dropdown. +fn page_object_mode(items: &[PageObjectItem]) -> BreadcrumbPillMode { + if items.is_empty() { + BreadcrumbPillMode::Placeholder + } else { + BreadcrumbPillMode::Interactive + } +} + /// Identity-primary context shared by the wallet + app-global identity pills. /// Derived once per frame, mirroring the hub's original derivation. struct AppGlobalContext { @@ -305,6 +323,7 @@ pub fn render( } IdentityPillScope::PageScopedObject { placeholder, + tooltip, items, selected, } => { @@ -312,6 +331,7 @@ pub fn render( ui, consumption, placeholder, + tooltip, items, *selected, dark_mode, @@ -555,26 +575,45 @@ fn render_app_global_identity_pill( /// Render the page-scoped object pill (masternode/evonode in view). `Consumed` /// opens a dropdown of `items` and emits [`GlobalNavEffect::SelectPageObject`] /// — never `SelectIdentity`; `Unwired` renders a subdued, non-interactive pill. +/// `tooltip` is the page's own copy for the interactive pill. +#[allow(clippy::too_many_arguments)] fn render_page_object_pill( ui: &mut Ui, consumption: &PillConsumption, placeholder: &str, + tooltip: &str, items: &[PageObjectItem], selected: Option, dark_mode: bool, effect: &mut GlobalNavEffect, ) { let label = page_object_label(placeholder, items, selected); + // The glyph belongs to the selected object; a placeholder carries none. + let icon = resolve_page_object(items, selected).and_then(|it| it.icon.clone()); + + if let PillConsumption::Unwired { + tooltip: how_to_change, + } = consumption + { + let mut pill = BreadcrumbPill::new(label).subdued(true); + if let Some(icon) = icon { + pill = pill.with_icon(icon); + } + pill.with_tooltip(how_to_change.clone()).show(ui); + return; + } - if let PillConsumption::Unwired { tooltip } = consumption { - BreadcrumbPill::new(label) - .subdued(true) - .with_tooltip(tooltip.clone()) - .show(ui); + let mut pill = BreadcrumbPill::new(label).with_mode(page_object_mode(items)); + if let Some(icon) = icon { + pill = pill.with_icon(icon); + } + // An empty page offers nothing to pick: placeholder only, no dropdown. + if items.is_empty() { + pill.show(ui); return; } - let resp = BreadcrumbPill::new(label).show(ui); + let resp = pill.with_tooltip(tooltip.to_string()).show(ui); if let Some(anchor) = resp.response.clone() { let popup_id = ui.make_persistent_id("global_nav_object_switcher"); egui::Popup::new(popup_id, ui.ctx().clone(), &anchor, anchor.layer_id) @@ -585,7 +624,11 @@ fn render_page_object_pill( ui.set_min_width(240.0); for it in items { let is_active = selected == Some(it.id); - if ui.selectable_label(is_active, &it.label).clicked() { + let row = match &it.icon { + Some(icon) => format!("{icon} {}", it.label), + None => it.label.clone(), + }; + if ui.selectable_label(is_active, row).clicked() { *effect = GlobalNavEffect::SelectPageObject(it.id); ui.close(); } @@ -642,33 +685,63 @@ mod tests { ); } - /// TC-NAV-04 foundation — a page-scoped selection resolves the pill label to - /// the selected item; a stale or absent selection falls back to placeholder. - #[test] - fn page_object_label_resolves_selection_else_placeholder() { - let items = vec![ + fn node_items() -> Vec { + vec![ PageObjectItem { id: id(1), label: "mn-east-01".to_string(), + icon: Some("🖥".to_string()), }, PageObjectItem { id: id(2), label: "evo-west-02".to_string(), + icon: Some("◆".to_string()), }, - ]; + ] + } + + /// TC-NAV-04 foundation — a page-scoped selection resolves the pill label to + /// the selected item; a stale or absent selection falls back to placeholder. + #[test] + fn page_object_label_resolves_selection_else_placeholder() { + let items = node_items(); assert_eq!( - page_object_label("(no masternode yet)", &items, Some(id(2))), + page_object_label("(choose a masternode)", &items, Some(id(2))), "evo-west-02" ); // Nothing selected → placeholder. assert_eq!( - page_object_label("(no masternode yet)", &items, None), - "(no masternode yet)" + page_object_label("(choose a masternode)", &items, None), + "(choose a masternode)" ); // Stale selection (not in items) → placeholder. assert_eq!( - page_object_label("(no masternode yet)", &items, Some(id(9))), - "(no masternode yet)" + page_object_label("(choose a masternode)", &items, Some(id(9))), + "(choose a masternode)" + ); + } + + /// The pill's glyph is the selected object's own; a placeholder (nothing or + /// a stale id selected) carries none. + #[test] + fn page_object_icon_belongs_to_the_selected_item() { + let items = node_items(); + assert_eq!( + resolve_page_object(&items, Some(id(2))).and_then(|it| it.icon.as_deref()), + Some("◆") + ); + assert!(resolve_page_object(&items, None).is_none()); + assert!(resolve_page_object(&items, Some(id(9))).is_none()); + } + + /// A page with no objects to offer renders a placeholder pill (no dropdown); + /// one or more objects make it interactive. + #[test] + fn page_object_pill_is_placeholder_until_objects_exist() { + assert_eq!(page_object_mode(&[]), BreadcrumbPillMode::Placeholder); + assert_eq!( + page_object_mode(&node_items()), + BreadcrumbPillMode::Interactive ); } diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 3d612ef2e..e63c214db 100644 --- a/src/ui/components/top_panel.rs +++ b/src/ui/components/top_panel.rs @@ -91,9 +91,10 @@ fn add_connection_indicator(ui: &mut Ui, app_context: &Arc) { )), |ui| { ui.horizontal(|ui| { + // Hover-only: the tooltip is the indicator's whole interaction. let (rect, resp) = ui.allocate_exact_size( egui::vec2(circle_size, circle_size), - egui::Sense::click(), + egui::Sense::hover(), ); let center = rect.center(); @@ -355,14 +356,26 @@ pub fn subdued_everyday_spec(label: impl Into, target: RootScreenType) - } /// A wallet-only global-nav spec (no identity/object pill) with the wallet pill -/// subdued (unwired). For pages with no identity context (e.g. Wallets) — -/// FR-GLOBAL-NAV-2 rule 4. +/// subdued (unwired). For pages with no identity context that do not yet consume +/// the wallet selection — FR-GLOBAL-NAV-2 rules 3 and 4. pub fn subdued_wallet_only_spec(label: impl Into, target: RootScreenType) -> PageNavSpec { PageNavSpec::new(label, target).with_wallet_pill(PillConsumption::Unwired { tooltip: TT_WALLET_UNWIRED.to_string(), }) } +/// A wallet-only global-nav spec whose wallet pill is **interactive** — for a +/// page with no identity context that consumes the wallet selection two-way +/// (FR-GLOBAL-NAV-2 rules 2 and 4). The page must mirror the resulting +/// [`GlobalNavEffect::SwitchWallet`] into its own state; see +/// [`add_top_panel_with_global_nav_capturing`]. +pub fn interactive_wallet_only_spec( + label: impl Into, + target: RootScreenType, +) -> PageNavSpec { + PageNavSpec::new(label, target).with_wallet_pill(PillConsumption::Consumed) +} + /// Apply a generalized global-nav effect: wallet/identity selection updates the /// **app-global** selection silently (no forced navigation — FR-GLOBAL-NAV-2 /// rule 1); segment-1 navigation and add-flows route to the existing screens. @@ -434,20 +447,24 @@ pub fn add_top_panel_with_global_nav( action } -/// Like [`add_top_panel_with_global_nav`], but also returns the page-scoped -/// object the user picked from an interactive page-scoped-object pill, if any. -/// This is the documented consumer of the page-scoped-object boundary pattern -/// (`IdentityPillScope::PageScopedObject` → `SelectPageObject`) for a page whose -/// breadcrumb carries an object pill: all other effects (segment-1 nav, wallet -/// switch) are applied here as usual, while `SelectPageObject` is **only** -/// surfaced to the caller — never written to `AppContext::selected_identity_id` -/// (the FR-6 boundary). Returns `(action, picked_page_object)`. +/// Like [`add_top_panel_with_global_nav`], but also returns the raw +/// [`GlobalNavEffect`] so a page that **consumes** a selection can mirror it into +/// its own view state — the page half of the two-way binding (FR-GLOBAL-NAV-2 +/// rule 2). The returned effect is **already applied**; a caller must only +/// synchronize its own state from it, never re-apply it. +/// +/// Two consumers today: the Wallets page mirrors +/// [`GlobalNavEffect::SwitchWallet`] into its selected wallet, and the +/// Masternodes page mirrors [`GlobalNavEffect::SelectPageObject`] by opening +/// that node's detail view. `SelectPageObject` is *only* surfaced here — the +/// applier never writes it to `AppContext::selected_identity_id` (the FR-6 +/// boundary). pub fn add_top_panel_with_global_nav_capturing( ui: &mut Ui, app_context: &Arc, spec: PageNavSpec, right_buttons: Vec<(&str, DesiredAppAction)>, -) -> (AppAction, Option) { +) -> (AppAction, GlobalNavEffect) { let mut effect = GlobalNavEffect::None; let mut selection = HubSelection::default(); let mut action = render_top_island( @@ -459,10 +476,6 @@ pub fn add_top_panel_with_global_nav_capturing( }, right_buttons, ); - let picked = match effect { - GlobalNavEffect::SelectPageObject(id) => Some(id), - _ => None, - }; - action |= apply_global_nav_effect(app_context, effect); - (action, picked) + action |= apply_global_nav_effect(app_context, effect.clone()); + (action, effect) } diff --git a/src/ui/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index 52e4a9390..260252a03 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -17,15 +17,17 @@ use crate::backend_task::identity::IdentityTask; use crate::context::AppContext; use crate::model::contested_name::MasternodeContestSummary; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; +use crate::ui::components::global_nav_switcher::GlobalNavEffect; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::add_top_panel_with_global_nav; +use crate::ui::components::top_panel::add_top_panel_with_global_nav_capturing; use crate::ui::identity::identity_pill::shorten_id; use crate::ui::identity::picker::compute_column_count; use crate::ui::masternodes::card::MasternodeCard; use crate::ui::masternodes::detail_screen::{DetailOutcome, MasternodeDetailView}; use crate::ui::masternodes::load_form::{LoadFormOutcome, MasternodeLoadForm}; -use crate::ui::state::masternodes_view::masternodes_page_nav_spec; +use crate::ui::state::global_nav::PageNavSpec; +use crate::ui::state::masternodes_view::{masternodes_page_nav_spec, node_pill_item}; use crate::ui::theme::{ComponentStyles, DashColors}; use crate::ui::{RootScreenType, ScreenLike}; @@ -232,6 +234,44 @@ impl MasternodesScreen { AppAction::None } + /// The node the page currently operates on — the one whose detail view is + /// open. The list and load views operate on no single node. + fn selected_node_id(&self) -> Option { + match &self.view { + MasternodesView::Detail(detail) => Some(detail.node_id()), + MasternodesView::List | MasternodesView::Load(_) => None, + } + } + + /// The page's global-nav spec: an interactive wallet pill plus a node pill + /// listing every loaded node and showing the one in view. Derived from the + /// page's own state each frame, which is what keeps the pill and the card + /// grid two-way bound (FR-GLOBAL-NAV-3). + fn nav_spec(&self) -> PageNavSpec { + let items = self + .nodes + .iter() + .map(|node| { + node_pill_item( + node.node_id, + node.alias.as_deref(), + &node.node_id_short, + node.node_type, + ) + }) + .collect(); + masternodes_page_nav_spec(items, self.selected_node_id()) + } + + /// Consume the global-nav effect this page is bound to: a node picked from + /// the node pill opens its detail view. Every other effect (segment-1 + /// navigation, a wallet switch) is already applied by the shared applier. + fn apply_nav_effect(&mut self, effect: GlobalNavEffect) { + if let GlobalNavEffect::SelectPageObject(node_id) = effect { + self.open_detail(node_id); + } + } + /// Open the detail view for `node_id`. Loads the node's full /// `QualifiedIdentity` from the local store; a lookup miss leaves the list /// view unchanged. @@ -405,14 +445,9 @@ impl ScreenLike for MasternodesScreen { } fn ui(&mut self, ui: &mut egui::Ui) -> AppAction { - // The Masternodes breadcrumb carries segment-1 + wallet pill only — no - // object/identity pill (locked decision #4: masternodes are never - // wallet-linked, so a wallet↔object pairing would misrepresent the - // relationship). Node selection is driven by card-click → detail and the - // `‹ All masternodes` back link; the FR-6 boundary is enforced at the - // resolution layer (B1), independent of this breadcrumb. - let spec = masternodes_page_nav_spec(); - let mut action = add_top_panel_with_global_nav(ui, &self.app_context, spec, vec![]); + let (mut action, effect) = + add_top_panel_with_global_nav_capturing(ui, &self.app_context, self.nav_spec(), vec![]); + self.apply_nav_effect(effect); action |= add_left_panel(ui, &self.app_context, RootScreenType::RootScreenMasternodes); @@ -546,4 +581,77 @@ mod tests { ctx.wallet_backend().expect("backend").shutdown().await; } + + /// FR-GLOBAL-NAV-3 — the node pill is two-way bound with the page: opening a + /// node (what a card click does) puts it on the pill, and picking a node + /// from the pill opens its detail view. The pill lists every loaded node. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn node_pill_is_two_way_bound_with_the_detail_view() { + let (ctx, _tmp) = offline_ctx().await; + seed_masternode(&ctx, 0x11); + seed_masternode(&ctx, 0x22); + let mut screen = MasternodesScreen::new(&ctx); + + // On the grid, no node is in view: the pill offers both, selects none. + let (scope, consumption) = screen + .nav_spec() + .identity_pill() + .cloned() + .expect("node pill"); + assert!(consumption.is_consumed(), "the node pill is interactive"); + assert!(scope.is_page_scoped(), "a node is never the app identity"); + assert_eq!(scope.page_scoped_selection(), None); + + // Grid → pill: opening a node's detail view puts it on the pill. + let node = Identifier::from([0x22; 32]); + screen.open_detail(node); + assert!(matches!(screen.view, MasternodesView::Detail(_))); + assert_eq!( + screen + .nav_spec() + .identity_pill() + .expect("node pill") + .0 + .page_scoped_selection(), + Some(node), + "the node in view must show on the pill", + ); + + // Pill → grid: picking the other node opens that node's detail view. + let other = Identifier::from([0x11; 32]); + screen.apply_nav_effect(GlobalNavEffect::SelectPageObject(other)); + let MasternodesView::Detail(detail) = &screen.view else { + panic!("picking a node from the pill must open its detail view"); + }; + assert_eq!(detail.node_id(), other); + + // A wallet switch is the shared applier's business — it must not move + // the page off the node in view. + screen.apply_nav_effect(GlobalNavEffect::SwitchWallet([0u8; 32])); + assert_eq!(screen.selected_node_id(), Some(other)); + + // Leaving the detail view clears the pill's selection. + screen.view = MasternodesView::List; + assert_eq!(screen.selected_node_id(), None); + + ctx.wallet_backend().expect("backend").shutdown().await; + } + + /// A node with no detail view open and no nodes at all both resolve to "no + /// selection" — the pill falls back to its placeholder rather than naming a + /// node the page is not showing. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn empty_page_offers_no_nodes_on_the_pill() { + let (ctx, _tmp) = offline_ctx().await; + let screen = MasternodesScreen::new(&ctx); + + let (scope, _) = screen + .nav_spec() + .identity_pill() + .cloned() + .expect("node pill"); + assert_eq!(scope.page_scoped_selection(), None); + + ctx.wallet_backend().expect("backend").shutdown().await; + } } diff --git a/src/ui/state/global_nav.rs b/src/ui/state/global_nav.rs index 816fe44de..77a10a21d 100644 --- a/src/ui/state/global_nav.rs +++ b/src/ui/state/global_nav.rs @@ -20,6 +20,9 @@ pub struct PageObjectItem { pub id: Identifier, /// Display label for the dropdown row and the pill when this item is active. pub label: String, + /// Type glyph shown ahead of the label, e.g. `HeroIdentityKind::type_glyph`. + /// `None` renders the label alone. + pub icon: Option, } /// How a breadcrumb pill participates on a given page (FR-GLOBAL-NAV-2 rule 3). @@ -60,6 +63,9 @@ pub enum IdentityPillScope { PageScopedObject { /// Label shown when nothing is selected, e.g. `(no masternode yet)`. placeholder: String, + /// Hover tooltip for the interactive pill. Page-owned copy, so the + /// switcher stays free of page-specific wording. + tooltip: String, /// The objects the dropdown offers. items: Vec, /// The currently selected object, if any. @@ -68,14 +74,16 @@ pub enum IdentityPillScope { } impl IdentityPillScope { - /// Build a page-scoped object scope with the given placeholder and items. + /// Build a page-scoped object scope from its page-owned copy and items. pub fn page_scoped_object( placeholder: impl Into, + tooltip: impl Into, items: Vec, selected: Option, ) -> Self { Self::PageScopedObject { placeholder: placeholder.into(), + tooltip: tooltip.into(), items, selected, } @@ -237,9 +245,11 @@ mod tests { fn page_scoped_object_is_isolated_from_app_global() { let scope = IdentityPillScope::page_scoped_object( "(no masternode yet)", + "Switch between your loaded masternodes and evonodes.", vec![PageObjectItem { id: id(7), label: "mn-east-01".to_string(), + icon: Some("🖥".to_string()), }], Some(id(7)), ); diff --git a/src/ui/state/masternodes_view.rs b/src/ui/state/masternodes_view.rs index c0973360d..04e89a139 100644 --- a/src/ui/state/masternodes_view.rs +++ b/src/ui/state/masternodes_view.rs @@ -1,55 +1,139 @@ //! Page-scoped view-model for the Masternodes global-nav breadcrumb (B7). //! //! Builds the Masternodes page's [`PageNavSpec`]: a page-aware `Masternodes` -//! segment-1 and an **interactive** wallet pill (funds Top up — FR-9). +//! segment-1, an **interactive** wallet pill (funds Top up — FR-9) and an +//! **interactive** page-scoped node pill (`🖥 mn-east-01 ▾` — FR-GLOBAL-NAV-3), +//! two-way bound with the card grid and the detail view. Renders nothing +//! (module-placement discriminator → `ui/state`). //! -//! The page deliberately carries **no** object/identity pill. Masternode and -//! evonode identities are never wallet-linked (`wallet_info` is always `None` -//! for them — locked decision #4). The breadcrumb's "wallet pill + object pill" -//! pairing expresses a genuine wallet↔identity relationship elsewhere in the -//! app (a wallet switch can reconcile a User identity); applying it here would -//! falsely imply a wallet↔masternode relationship that does not exist. Node -//! selection is driven entirely by card-click → detail and the detail / -//! load-form `‹ All masternodes` back link. Renders nothing (module-placement -//! discriminator → `ui/state`). -//! -//! The FR-6 boundary (a masternode never becoming the app-global identity) is -//! enforced structurally at the resolution layer (B1), independent of whether -//! any pill renders in this breadcrumb. +//! FR-6 boundary: the node pill carries a **page-scoped** selection, distinct +//! from the app-global user identity. The switcher maps it to +//! `GlobalNavEffect::SelectPageObject`, never `SelectIdentity`, so a masternode +//! can never become the app-global identity, nor surface in the everyday-user +//! identity picker. The boundary is additionally enforced at the resolution +//! layer (B1). + +use dash_sdk::platform::Identifier; +use crate::model::qualified_identity::IdentityType; use crate::ui::RootScreenType; -use crate::ui::state::global_nav::{PageNavSpec, PillConsumption}; +use crate::ui::identity::identity_hero_card::HeroIdentityKind; +use crate::ui::masternodes::card::card_heading; +use crate::ui::state::global_nav::{ + IdentityPillScope, PageNavSpec, PageObjectItem, PillConsumption, +}; + +/// Node-pill label when no node is loaded yet. +const NO_NODES_PLACEHOLDER: &str = "(no masternode yet)"; +/// Node-pill label when nodes are loaded but none is open. +const NO_NODE_SELECTED_PLACEHOLDER: &str = "(choose a masternode)"; +/// Hover tooltip for the interactive node pill. +const TT_NODE_PILL: &str = "Switch between your loaded masternodes and evonodes."; + +/// Dropdown label for a loaded node: its alias when set, otherwise the +/// shortened ProTxHash — the same heading rule as its card, so the pill and the +/// grid always name a node identically. +pub fn node_pill_item( + node_id: Identifier, + alias: Option<&str>, + node_id_short: &str, + node_type: IdentityType, +) -> PageObjectItem { + let kind: HeroIdentityKind = node_type.into(); + PageObjectItem { + id: node_id, + label: card_heading(alias, node_id_short), + icon: Some(kind.type_glyph().to_string()), + } +} -/// Build the Masternodes page's global-nav spec: a page-aware `Masternodes` -/// segment-1 plus an interactive wallet pill. No object/identity pill — see the -/// module docs for why (locked decision #4). -pub fn masternodes_page_nav_spec() -> PageNavSpec { +/// The node-pill placeholder: nothing loaded yet vs. loaded but none open. +fn node_placeholder(node_count: usize) -> &'static str { + if node_count == 0 { + NO_NODES_PLACEHOLDER + } else { + NO_NODE_SELECTED_PLACEHOLDER + } +} + +/// Build the Masternodes page's global-nav spec: page-aware segment-1, an +/// interactive wallet pill, and an interactive node pill listing `items` with +/// `selected` (the node whose detail view is open) shown on the pill. +pub fn masternodes_page_nav_spec( + items: Vec, + selected: Option, +) -> PageNavSpec { + let placeholder = node_placeholder(items.len()); PageNavSpec::new("Masternodes", RootScreenType::RootScreenMasternodes) .with_wallet_pill(PillConsumption::Consumed) + .with_identity_pill( + IdentityPillScope::page_scoped_object(placeholder, TT_NODE_PILL, items, selected), + PillConsumption::Consumed, + ) } #[cfg(test)] mod tests { use super::*; - /// The Masternodes breadcrumb exposes segment-1 and an interactive wallet - /// pill, and — deliberately — NO object/identity pill (locked decision #4: - /// masternodes are never wallet-linked, so a wallet↔object pairing would - /// misrepresent the relationship). Card-click/detail drive node selection. + fn id(byte: u8) -> Identifier { + Identifier::new([byte; 32]) + } + + /// The Masternodes breadcrumb carries segment-1, an interactive wallet pill + /// (FR-9 Top up) and an interactive page-scoped node pill carrying the node + /// in view (FR-GLOBAL-NAV-3). #[test] - fn spec_has_segment1_and_wallet_pill_but_no_object_pill() { - let spec = masternodes_page_nav_spec(); + fn spec_has_segment1_and_two_interactive_pills() { + let items = vec![node_pill_item( + id(7), + Some("mn-east-01"), + "abcd…ef01", + IdentityType::Masternode, + )]; + let spec = masternodes_page_nav_spec(items, Some(id(7))); + assert_eq!(spec.segment1_label(), "Masternodes"); assert_eq!( spec.segment1_target(), RootScreenType::RootScreenMasternodes ); - // Wallet pill interactive (FR-9 Top up). assert!(spec.wallet_pill().expect("wallet pill").is_consumed()); - // No object/identity pill on this page. + + let (scope, consumption) = spec.identity_pill().expect("node pill"); + assert!(consumption.is_consumed(), "the node pill is interactive"); assert!( - spec.identity_pill().is_none(), - "the Masternodes breadcrumb must carry no object/identity pill", + scope.is_page_scoped(), + "the node pill is page-scoped, never the app-global identity", ); + assert_eq!(scope.page_scoped_selection(), Some(id(7))); + } + + /// The placeholder distinguishes "none loaded" from "none open", mirroring + /// the identity pill's `(no identity yet)` / `(choose an identity)` rule. + #[test] + fn placeholder_distinguishes_no_nodes_from_none_selected() { + assert_eq!(node_placeholder(0), "(no masternode yet)"); + assert_eq!(node_placeholder(3), "(choose a masternode)"); + } + + /// A node's pill label follows its card heading (alias, else shortened + /// ProTxHash) and its glyph follows its type. + #[test] + fn node_item_label_follows_card_heading_and_glyph_follows_type() { + let aliased = node_pill_item( + id(1), + Some("mn-east-01"), + "abcd…ef01", + IdentityType::Masternode, + ); + assert_eq!(aliased.label, "mn-east-01"); + assert_eq!(aliased.icon.as_deref(), Some("\u{1F5A5}")); + + let anonymous = node_pill_item(id(2), None, "abcd…ef01", IdentityType::Masternode); + assert_eq!(anonymous.label, "abcd…ef01"); + + let evonode = node_pill_item(id(3), None, "beef…cafe", IdentityType::Evonode); + assert_eq!(evonode.icon.as_deref(), Some("\u{25C6}")); } } diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 2540cc712..17181411b 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -18,10 +18,13 @@ use crate::model::wallet::{TransactionStatus, Wallet, WalletSeedHash, WalletTran use crate::ui::components::MessageBanner; use crate::ui::components::component_trait::Component; use crate::ui::components::confirmation_dialog::{ConfirmationDialog, ConfirmationStatus}; +use crate::ui::components::global_nav_switcher::GlobalNavEffect; use crate::ui::components::left_panel::add_left_panel; use crate::ui::components::password_input::PasswordInput; use crate::ui::components::styled::island_central_panel; -use crate::ui::components::top_panel::{add_top_panel_with_global_nav, subdued_wallet_only_spec}; +use crate::ui::components::top_panel::{ + add_top_panel_with_global_nav_capturing, interactive_wallet_only_spec, +}; use crate::ui::components::wallet_unlock_popup::{WalletUnlockPopup, WalletUnlockResult}; use crate::ui::helpers::clicked_outside_window; use crate::ui::helpers::copy_text_to_clipboard; @@ -388,6 +391,44 @@ impl WalletsBalancesScreen { self.persist_selected_single_key_hash(None); } + /// Select the HD wallet with `seed_hash`, if this network has it. Used to + /// mirror a wallet chosen elsewhere — the global-nav pill, or another page — + /// into this page's own selection. An unknown hash leaves the selection + /// untouched. + fn select_hd_wallet_by_hash(&mut self, seed_hash: WalletSeedHash) { + let wallet = self + .app_context + .wallets + .read() + .ok() + .and_then(|wallets| wallets.get(&seed_hash).cloned()); + if let Some(wallet) = wallet { + self.select_hd_wallet(wallet); + } + } + + /// Consume the global-nav effect this page is bound to: a wallet switched on + /// the pill becomes this page's selected wallet (FR-GLOBAL-NAV-2 rule 2). + /// The app-global selection itself is already written by the shared applier. + fn apply_nav_effect(&mut self, effect: GlobalNavEffect) { + if let GlobalNavEffect::SwitchWallet(seed_hash) = effect { + self.select_hd_wallet_by_hash(seed_hash); + } + } + + /// Adopt the app-global wallet selection when it names a different HD wallet + /// than this page shows — it was switched from another page's nav pill while + /// this screen was away. Leaves a selected single-key wallet alone: that + /// choice clears the app-global HD selection, so there is nothing to adopt. + fn adopt_app_global_wallet_selection(&mut self) { + let Some(seed_hash) = self.app_context.selected_wallet_hash() else { + return; + }; + if self.selected_wallet_seed_hash() != Some(seed_hash) { + self.select_hd_wallet_by_hash(seed_hash); + } + } + fn select_single_key_wallet(&mut self, wallet: Arc>) { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; @@ -2423,13 +2464,13 @@ impl ScreenLike for WalletsBalancesScreen { DesiredAppAction::Custom("RefreshSKWallet".to_string()), )); } - // TODO: wire wallet selection consumption for the Wallets page. - let mut action = add_top_panel_with_global_nav( + let (mut action, effect) = add_top_panel_with_global_nav_capturing( ui, &self.app_context, - subdued_wallet_only_spec("Wallets", RootScreenType::RootScreenWalletsBalances), + interactive_wallet_only_spec("Wallets", RootScreenType::RootScreenWalletsBalances), right_buttons, ); + self.apply_nav_effect(effect); action |= add_left_panel( ui, @@ -3114,6 +3155,12 @@ impl ScreenLike for WalletsBalancesScreen { } } + // A wallet switched on another page's nav pill must be the one this page + // shows on arrival — the pill reads the app-global selection every frame, + // so a stale in-screen selection would make the two disagree. Runs before + // the first-wallet default below, which would otherwise win over it. + self.adopt_app_global_wallet_selection(); + // If no wallet of either type is selected but wallets exist, select the first HD wallet if self.selected_wallet.is_none() && self.selected_single_key_wallet.is_none() { let next_hd = self @@ -3276,4 +3323,119 @@ mod tests { let tabs = plan_account_tabs(&[], true, false, true); assert!(tabs.contains(&AccountTab::System)); } + + /// Build an offline `AppContext` (no network I/O, throwaway data dir). + fn offline_ctx() -> (Arc, tempfile::TempDir) { + use crate::app_dir::ensure_env_file; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + (ctx, temp_dir) + } + + /// Register an HD wallet derived from a distinct seed, returning its hash. + fn seed_hd_wallet(ctx: &Arc, seed_byte: u8) -> WalletSeedHash { + let wallet = + Wallet::new_from_seed([seed_byte; 64], ctx.network(), None, None).expect("wallet"); + let seed_hash = wallet.seed_hash(); + ctx.wallets + .write() + .expect("wallets") + .insert(seed_hash, Arc::new(RwLock::new(wallet))); + seed_hash + } + + /// FR-GLOBAL-NAV-2 rule 2 — the Wallets page is two-way bound with the nav + /// wallet pill. Switching on the pill selects that wallet on the page; the + /// page's own selection is what the pill reads back (the app-global hash). + /// An unknown wallet leaves the selection untouched. + #[test] + fn wallet_pill_switch_selects_that_wallet_on_the_page() { + let (ctx, _tmp) = offline_ctx(); + let first = seed_hd_wallet(&ctx, 0xAA); + let second = seed_hd_wallet(&ctx, 0xBB); + let mut screen = WalletsBalancesScreen::new(&ctx); + + screen.select_hd_wallet_by_hash(first); + assert_eq!(screen.selected_wallet_seed_hash(), Some(first)); + + // Pill → page. + screen.apply_nav_effect(GlobalNavEffect::SwitchWallet(second)); + assert_eq!(screen.selected_wallet_seed_hash(), Some(second)); + // Page → pill: the pill renders from the app-global hash every frame. + assert_eq!(ctx.selected_wallet_hash(), Some(second)); + + // A wallet this network does not have changes nothing. + screen.apply_nav_effect(GlobalNavEffect::SwitchWallet([0xEE; 32])); + assert_eq!(screen.selected_wallet_seed_hash(), Some(second)); + } + + /// A wallet switched from another page's nav pill while this screen was away + /// is adopted on arrival — otherwise the pill and the page would disagree. + #[test] + fn arriving_adopts_a_wallet_switched_elsewhere() { + let (ctx, _tmp) = offline_ctx(); + let first = seed_hd_wallet(&ctx, 0xAA); + let second = seed_hd_wallet(&ctx, 0xBB); + let mut screen = WalletsBalancesScreen::new(&ctx); + screen.select_hd_wallet_by_hash(first); + + // Another page's pill switches the app-global wallet. + ctx.set_selected_hd_wallet(Some(second)); + screen.refresh_on_arrival(); + + assert_eq!(screen.selected_wallet_seed_hash(), Some(second)); + } + + /// Arriving with nothing selected must honour the app-global wallet, not the + /// first-wallet default — the default would otherwise silently overrule a + /// wallet the user picked from the nav pill on another page. + #[test] + fn arriving_with_no_selection_honours_the_app_global_wallet() { + let (ctx, _tmp) = offline_ctx(); + // Built before any wallet exists → the screen starts with no selection. + let mut screen = WalletsBalancesScreen::new(&ctx); + seed_hd_wallet(&ctx, 0xAA); + seed_hd_wallet(&ctx, 0xBB); + + // Target the wallet the first-wallet default would NOT pick. + let hashes: Vec = ctx + .wallets + .read() + .expect("wallets") + .keys() + .copied() + .collect(); + let default_pick = hashes.first().copied().expect("a wallet"); + let target = hashes.last().copied().expect("a wallet"); + assert_ne!( + default_pick, target, + "the two wallets must be distinguishable" + ); + + ctx.set_selected_hd_wallet(Some(target)); + screen.refresh_on_arrival(); + + assert_eq!(screen.selected_wallet_seed_hash(), Some(target)); + } } diff --git a/tests/kittest/global_nav_switcher.rs b/tests/kittest/global_nav_switcher.rs index d9acbd888..857bc2a73 100644 --- a/tests/kittest/global_nav_switcher.rs +++ b/tests/kittest/global_nav_switcher.rs @@ -9,7 +9,9 @@ use dash_evo_tool::app::AppAction; use dash_evo_tool::ui::RootScreenType; use dash_evo_tool::ui::components::global_nav_switcher::{self, GlobalNavEffect}; use dash_evo_tool::ui::components::top_panel::apply_global_nav_effect; -use dash_evo_tool::ui::state::global_nav::{IdentityPillScope, PageNavSpec, PillConsumption}; +use dash_evo_tool::ui::state::global_nav::{ + IdentityPillScope, PageNavSpec, PageObjectItem, PillConsumption, +}; use dash_evo_tool::ui::state::hub_selection::HubSelection; use dash_sdk::platform::Identifier; use egui_kittest::Harness; @@ -59,6 +61,7 @@ fn page_scoped_pill_renders_placeholder_when_empty() { .with_identity_pill( IdentityPillScope::page_scoped_object( "(no masternode yet)", + "Switch between your loaded masternodes and evonodes.", vec![], None, ), @@ -77,6 +80,57 @@ fn page_scoped_pill_renders_placeholder_when_empty() { }); } +/// TC-NAV-04 — the interactive page-scoped pill names the object in view, not +/// its placeholder, and offers the page's objects. +#[test] +fn page_scoped_pill_names_the_selected_object() { + with_isolated_data_dir(|| { + let (_rt, app_context) = fresh_app_context(); + let mut harness = Harness::builder() + .with_size(egui::vec2(900.0, 200.0)) + .build_ui(move |ui| { + let mut selection = HubSelection::default(); + let items = vec![ + PageObjectItem { + id: Identifier::new([1; 32]), + label: "mn-east-01".to_string(), + icon: Some("🖥".to_string()), + }, + PageObjectItem { + id: Identifier::new([2; 32]), + label: "evo-west-02".to_string(), + icon: Some("◆".to_string()), + }, + ]; + let spec = + PageNavSpec::new("Masternodes", RootScreenType::RootScreenWalletsBalances) + .with_wallet_pill(PillConsumption::Consumed) + .with_identity_pill( + IdentityPillScope::page_scoped_object( + "(choose a masternode)", + "Switch between your loaded masternodes and evonodes.", + items, + Some(Identifier::new([2; 32])), + ), + PillConsumption::Consumed, + ); + global_nav_switcher::render(ui, &app_context, &spec, &mut selection); + }); + harness.run(); + + assert!( + harness.query_by_label_contains("evo-west-02").is_some(), + "the pill must name the object in view" + ); + assert!( + harness + .query_by_label_contains("(choose a masternode)") + .is_none(), + "a resolved selection must replace the placeholder" + ); + }); +} + /// TC-NAV-13 — an unwired pill renders subdued (non-interactive): the wallet /// placeholder still shows, but with no dropdown wiring. Here it renders on a /// spec whose wallet pill is unwired; the value/placeholder is visible. From 4902fb569c474e81b35276e8de8e939d677f52aa Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:15:52 +0000 Subject: [PATCH 11/30] fix(dashpay): narrow the cancel race window and add a way back for hidden contacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/dashpay/contact_requests.rs | 351 +++++++++++++++---- src/ui/dashpay/contacts_list.rs | 59 +--- src/ui/identity/contacts.rs | 158 ++++++++- src/ui/identity/hub_screen.rs | 11 + src/ui/state/contacts_view.rs | 228 ++++++++++-- 5 files changed, 669 insertions(+), 138 deletions(-) diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index c301fc08e..b9f0caa02 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -29,6 +29,7 @@ use dash_sdk::platform::{ use dash_sdk::query_types::{CurrentQuorumsInfo, NoParamQuery}; use platform_wallet::ContactRequest as UpstreamContactRequest; use std::collections::{BTreeMap, HashSet}; +use std::future::Future; use std::sync::Arc; pub async fn load_contact_requests( @@ -900,6 +901,131 @@ pub async fn reject_contact_request( )) } +/// What a cancellation attempt resolved to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CancelOutcome { + /// The request was pending throughout; it is now withdrawn. + Withdrawn, + /// The recipient had answered — there was nothing left to cancel. + AlreadyEstablished, +} + +/// The Platform reads and writes a cancellation performs. +/// +/// Behind a trait so [`cancel_flow`]'s ordering — and the race window between +/// the reciprocal check and the hide broadcast — can be driven deterministically +/// in tests, which is impossible against a live Platform. +trait CancelOps { + /// `true` when the recipient has already sent a contact request back, which + /// makes the pending request an established contact instead. + fn reciprocal_request_exists(&self) -> impl Future> + Send; + + /// Broadcast a `contactInfo` document carrying `hidden` for the recipient. + fn set_contact_hidden( + &self, + hidden: bool, + ) -> impl Future> + Send; + + /// Record the withdrawal in the DET sidecar so the request stops being + /// listed as pending. + fn mark_withdrawn(&self); +} + +/// Check, hide, then re-check and undo the hide if the recipient answered inside +/// the window. +/// +/// Platform has no conditional write, so the reciprocal check and the hide +/// broadcast cannot be one atomic step: they are two round-trips against two +/// different documents. The check is therefore the last read before the write, +/// and a second read straight after the write closes the gap retroactively — a +/// reciprocal request that lands mid-flight is detected and the hide is +/// reverted, leaving the freshly-established contact visible. That is the same +/// end state the caller would have reached had the reciprocal arrived a moment +/// earlier. +/// +/// Residual risk: a reciprocal request landing *after* the second read still +/// leaves the contact hidden. It is not detectable from here at any window +/// width; recovery is the Contacts tab's hidden-contacts section, which can +/// unhide the contact. +async fn cancel_flow(ops: &O) -> Result { + if ops.reciprocal_request_exists().await? { + return Ok(CancelOutcome::AlreadyEstablished); + } + + ops.set_contact_hidden(true).await?; + + if ops.reciprocal_request_exists().await? { + ops.set_contact_hidden(false).await?; + return Ok(CancelOutcome::AlreadyEstablished); + } + + ops.mark_withdrawn(); + Ok(CancelOutcome::Withdrawn) +} + +/// [`CancelOps`] against the live Platform, for one sender/recipient pair. +struct PlatformCancelOps<'a> { + app_context: &'a Arc, + sdk: &'a Sdk, + identity: QualifiedIdentity, + /// The cancelling identity — the sender of the request being withdrawn. + owner_id: Identifier, + /// The recipient of the request being withdrawn. + to_identity_id: Identifier, +} + +impl CancelOps for PlatformCancelOps<'_> { + async fn reciprocal_request_exists(&self) -> Result { + let mut query = + DocumentQuery::new(self.app_context.dashpay_contract.clone(), "contactRequest") + .map_err(|e| DashPayError::QueryCreation { + query_target: "DashPay contactRequest", + source: Box::new(e), + })?; + query = query + .with_where(WhereClause { + field: "$ownerId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(self.to_identity_id.to_buffer()), + }) + .with_where(WhereClause { + field: "toUserId".to_string(), + operator: WhereOperator::Equal, + value: Value::Identifier(self.owner_id.to_buffer()), + }); + query.limit = 1; + + Ok(!Document::fetch_many(self.sdk, query).await?.is_empty()) + } + + async fn set_contact_hidden(&self, hidden: bool) -> Result<(), TaskError> { + super::contact_info::create_or_update_contact_info( + self.app_context, + self.sdk, + self.identity.clone(), + self.to_identity_id, + None, // No nickname + None, // No note + hidden, // display_hidden + Vec::new(), // No accepted accounts + ) + .await + .map(|_| ()) + } + + fn mark_withdrawn(&self) { + if let Ok(backend) = self.app_context.wallet_backend() + && let Err(e) = backend.dashpay_mark_rejected(&self.owner_id, &self.to_identity_id) + { + tracing::debug!( + to = %self.to_identity_id.to_string(Encoding::Base58), + error = ?e, + "DashPay cancellation sidecar write failed; request will still display as pending" + ); + } + } +} + /// Cancel a contact request this identity sent and that is still pending. /// /// DashPay `contactRequest` documents are immutable and cannot be deleted @@ -912,95 +1038,49 @@ pub async fn reject_contact_request( /// /// State is re-verified against Platform before acting: the request must still /// exist, must have been sent by `identity`, and must not have been answered in -/// the meantime. +/// the meantime. See [`cancel_flow`] for how the answer race is handled. pub async fn cancel_contact_request( app_context: &Arc, sdk: &Sdk, identity: QualifiedIdentity, request_id: Identifier, ) -> Result { - let dashpay_contract = app_context.dashpay_contract.clone(); let owner_id = identity.identity.id(); - // Step 1: re-fetch the request rather than trusting the row the user - // clicked — the list may be stale by seconds or by an identity switch. - let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest").map_err(|e| { - DashPayError::QueryCreation { + // Re-fetch the request rather than trusting the row the user clicked — the + // list may be stale by seconds or by an identity switch. + let query = DocumentQuery::new(app_context.dashpay_contract.clone(), "contactRequest") + .map_err(|e| DashPayError::QueryCreation { query_target: "DashPay contactRequest", source: Box::new(e), - } - })?; + })?; let query_with_id = DocumentQuery::with_document_id(query, &request_id); let doc = Document::fetch(sdk, query_with_id) .await? .ok_or(TaskError::DocumentNotFound)?; - // Step 2: verify we sent it, and to whom. + // Verify we sent it, and to whom. let to_identity_id = recipient_of_sent_request(&doc, &owner_id)?; - // Step 3: if the recipient already sent one back, the request is no longer - // pending — it is an established contact. Cancelling it would be a lie, so - // report the real state and let the UI refresh into it. - let mut reciprocal_query = - DocumentQuery::new(dashpay_contract, "contactRequest").map_err(|e| { - DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - } - })?; - reciprocal_query = reciprocal_query - .with_where(WhereClause { - field: "$ownerId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(to_identity_id.to_buffer()), - }) - .with_where(WhereClause { - field: "toUserId".to_string(), - operator: WhereOperator::Equal, - value: Value::Identifier(owner_id.to_buffer()), - }); - reciprocal_query.limit = 1; - - if !Document::fetch_many(sdk, reciprocal_query) - .await? - .is_empty() - { - return Ok(BackendTaskSuccessResult::DashPayContactAlreadyEstablished( - to_identity_id, - )); - } - - // Step 4: broadcast the withdrawal as a hidden contactInfo document. This is - // the only part of a cancellation that Platform can carry. - super::contact_info::create_or_update_contact_info( + let ops = PlatformCancelOps { app_context, sdk, identity, + owner_id, to_identity_id, - None, // No nickname - None, // No note - true, // display_hidden = true for a withdrawn request - Vec::new(), // No accepted accounts - ) - .await?; + }; - // Step 5: mirror the withdrawal into the DET sidecar. `load_contact_requests` - // consults this, so the row stops being listed even though the document - // itself lives on forever. - if let Ok(backend) = app_context.wallet_backend() - && let Err(e) = backend.dashpay_mark_rejected(&owner_id, &to_identity_id) - { - tracing::debug!( - to = %to_identity_id.to_string(Encoding::Base58), - error = ?e, - "DashPay cancellation sidecar write failed; request will still display as pending" - ); + match cancel_flow(&ops).await? { + CancelOutcome::Withdrawn => Ok(BackendTaskSuccessResult::DashPayContactRequestCancelled( + request_id, + )), + // The recipient answered: the pair is a contact now, so report the real + // state and let the UI refresh into it. + CancelOutcome::AlreadyEstablished => Ok( + BackendTaskSuccessResult::DashPayContactAlreadyEstablished(to_identity_id), + ), } - - Ok(BackendTaskSuccessResult::DashPayContactRequestCancelled( - request_id, - )) } #[cfg(test)] @@ -1101,4 +1181,145 @@ mod tests { "a request we cannot attribute must stay visible rather than be silently hidden" ); } + + // ----------------------------------------------------------------- + // Cancellation flow — the reciprocal check and the hide broadcast are + // separate Platform round-trips, so the window between them is where a + // reciprocal request can slip in and get a fresh contact hidden. + // ----------------------------------------------------------------- + + use std::collections::VecDeque; + use std::sync::Mutex; + + /// Scriptable [`CancelOps`]. `reciprocal` is consumed one answer per probe, + /// which is how a reciprocal request is injected *inside* the window: + /// `[false, true]` means "pending when we checked, established by the time + /// the hide landed". + #[derive(Default)] + struct ScriptedOps { + reciprocal: Mutex>, + /// Every `set_contact_hidden` argument, in call order. + hidden_writes: Mutex>, + withdrawn: Mutex, + /// When set, the first hide broadcast fails. + hide_fails: bool, + } + + impl ScriptedOps { + fn with_reciprocal(answers: [bool; 2]) -> Self { + Self { + reciprocal: Mutex::new(answers.into()), + ..Default::default() + } + } + + fn hidden_writes(&self) -> Vec { + self.hidden_writes.lock().expect("not poisoned").clone() + } + + fn was_withdrawn(&self) -> bool { + *self.withdrawn.lock().expect("not poisoned") + } + } + + impl CancelOps for ScriptedOps { + async fn reciprocal_request_exists(&self) -> Result { + Ok(self + .reciprocal + .lock() + .expect("not poisoned") + .pop_front() + .unwrap_or(false)) + } + + async fn set_contact_hidden(&self, hidden: bool) -> Result<(), TaskError> { + if self.hide_fails { + return Err(TaskError::DocumentNotFound); + } + self.hidden_writes + .lock() + .expect("not poisoned") + .push(hidden); + Ok(()) + } + + fn mark_withdrawn(&self) { + *self.withdrawn.lock().expect("not poisoned") = true; + } + } + + #[tokio::test] + async fn cancelling_a_pending_request_hides_it_and_records_the_withdrawal() { + let ops = ScriptedOps::with_reciprocal([false, false]); + + let outcome = cancel_flow(&ops).await.expect("cancellation succeeds"); + + assert_eq!(outcome, CancelOutcome::Withdrawn); + assert_eq!( + ops.hidden_writes(), + vec![true], + "a pending request must be hidden exactly once" + ); + assert!( + ops.was_withdrawn(), + "the withdrawal must be recorded so the row stops being listed" + ); + } + + #[tokio::test] + async fn cancelling_an_answered_request_hides_nothing() { + // The recipient answered before the user clicked Cancel. + let ops = ScriptedOps::with_reciprocal([true, true]); + + let outcome = cancel_flow(&ops).await.expect("cancellation succeeds"); + + assert_eq!(outcome, CancelOutcome::AlreadyEstablished); + assert!( + ops.hidden_writes().is_empty(), + "an established contact must never be hidden by a cancellation" + ); + assert!(!ops.was_withdrawn()); + } + + #[tokio::test] + async fn a_reciprocal_request_landing_inside_the_window_leaves_the_contact_visible() { + // Pending when checked, established by the time the hide broadcast + // landed — the exact race the check-then-write ordering cannot prevent. + let ops = ScriptedOps::with_reciprocal([false, true]); + + let outcome = cancel_flow(&ops).await.expect("cancellation succeeds"); + + assert_eq!( + outcome, + CancelOutcome::AlreadyEstablished, + "the caller must be told the pair is a contact, not that a request was cancelled" + ); + assert_eq!( + ops.hidden_writes(), + vec![true, false], + "the hide must be undone once the reciprocal request is detected, so the \ + freshly-established contact does not vanish" + ); + assert!( + !ops.was_withdrawn(), + "an established contact must not be marked as a withdrawn request" + ); + } + + #[tokio::test] + async fn a_failed_hide_broadcast_records_no_withdrawal() { + let ops = ScriptedOps { + hide_fails: true, + ..ScriptedOps::with_reciprocal([false, false]) + }; + + assert!( + cancel_flow(&ops).await.is_err(), + "a failed broadcast must surface, not be swallowed" + ); + assert!( + !ops.was_withdrawn(), + "the request is still pending on Platform, so it must keep being listed" + ); + } } diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 0c5ee3efa..79eeacc55 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -10,6 +10,7 @@ use crate::ui::components::wallet_unlock_popup::WalletUnlockResult; use crate::ui::dashpay::contact_requests::ContactRequests; use crate::ui::dashpay::persist_contact_private_info; use crate::ui::state::AvatarCache; +use crate::ui::state::contacts_view::{ContactSearchFields, matches_contact_search}; use crate::ui::theme::DashColors; use crate::ui::{MessageType, ScreenLike, ScreenType}; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; @@ -32,6 +33,18 @@ pub struct Contact { pub created_at: Option, } +impl<'a> From<&'a Contact> for ContactSearchFields<'a> { + fn from(contact: &'a Contact) -> Self { + Self { + nickname: contact.nickname.as_deref(), + display_name: contact.display_name.as_deref(), + username: contact.username.as_deref(), + bio: contact.bio.as_deref(), + identity_id: contact.identity_id, + } + } +} + #[derive(Debug, Clone, PartialEq)] pub enum SearchFilter { All, @@ -531,7 +544,7 @@ impl ContactsList { } // Filter contacts based on search, filter, and hidden status - let query = self.search_query.to_lowercase(); + let query = self.search_query.clone(); let mut filtered_contacts: Vec<_> = self .contacts @@ -565,49 +578,7 @@ impl ContactsList { return false; } - // Filter by search query - if query.is_empty() { - return true; - } - - // Enhanced search functionality - let search_in_text = |text: &str| text.to_lowercase().contains(&query); - - // Search in username - if let Some(username) = &contact.username - && search_in_text(username) - { - return true; - } - - // Search in display name - if let Some(display_name) = &contact.display_name - && search_in_text(display_name) - { - return true; - } - - // Search in nickname - if let Some(nickname) = &contact.nickname - && search_in_text(nickname) - { - return true; - } - - // Search in bio - if let Some(bio) = &contact.bio - && search_in_text(bio) - { - return true; - } - - // Search in identity ID (partial match) - let identity_str = contact.identity_id.to_string(Encoding::Base58); - if search_in_text(&identity_str) { - return true; - } - - false + matches_contact_search(ContactSearchFields::from(*contact), &query) }) .cloned() .collect(); diff --git a/src/ui/identity/contacts.rs b/src/ui/identity/contacts.rs index 4db0a2341..c093508f2 100644 --- a/src/ui/identity/contacts.rs +++ b/src/ui/identity/contacts.rs @@ -18,7 +18,7 @@ use super::request_card::{RequestAction, RequestCard}; use super::social_profile_gate_card::SocialProfileGateCard; use crate::app::AppAction; use crate::backend_task::BackendTask; -use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::dashpay::{ContactData, DashPayTask}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; @@ -46,6 +46,18 @@ pub const NO_ACTIVE_EMPTY: &str = "You have no contacts yet."; pub const NO_SENT_EMPTY: &str = "No outgoing requests."; pub const NO_SEARCH_MATCH: &str = "No contact matches your search."; pub const SEARCH_PLACEHOLDER: &str = "Search your contacts"; +pub const UNHIDE_LABEL: &str = "Unhide"; +pub const UNHIDE_TOOLTIP: &str = "Show this contact in your contact list again."; + +/// Hidden-section toggle. Shown only when at least one contact is hidden, so the +/// count tells the user there is something to recover. +pub const SHOW_HIDDEN_LABEL: &str = "Show hidden contacts"; + +/// Why a contact the user never hid by hand can still be in this section: +/// declining or cancelling a request hides that person, and a contact that is +/// later established with them stays hidden until it is unhidden here. +pub const HIDDEN_EXPLAINER: &str = "Hidden contacts are kept out of your list. Declining or cancelling a request also hides that \ + person. Unhide anyone you want back."; /// Sent-section caption. A DashPay contact request cannot be deleted from the /// network once sent, so the copy states plainly what Cancel really does @@ -133,6 +145,22 @@ pub fn request_task( } } +/// Backend task that makes a hidden contact visible again. +/// +/// Unhiding is a `contactInfo` broadcast with `display_hidden` cleared — the +/// same document the decline / cancel paths set it on. The contact's nickname +/// and note ride along so restoring visibility does not wipe them. +pub fn unhide_task(identity: QualifiedIdentity, contact: &ContactData) -> DashPayTask { + DashPayTask::UpdateContactInfo { + identity, + contact_id: contact.identity_id, + nickname: contact.nickname.clone(), + note: contact.note.clone(), + is_hidden: false, + accepted_accounts: Vec::new(), + } +} + /// Public entry point invoked by `hub_screen` when the Contacts tab is active. /// /// Resolves the "current" identity as the app-scoped active identity. When no @@ -399,11 +427,78 @@ fn active_section( if add.clicked() { action = resolve_contacts_button(ContactsButton::ActiveAddByUsername, app_context); } + + action |= hidden_section(ui, identity, state, dark_mode); }); action } +/// Hidden contacts — the way back for anyone the user hid, declined, or +/// cancelled on. Collapsed behind a toggle, and drawn only when there is +/// something to recover, so it stays out of the way in the common case. +fn hidden_section( + ui: &mut Ui, + identity: &QualifiedIdentity, + state: &mut ContactsState, + dark_mode: bool, +) -> AppAction { + let mut action = AppAction::None; + if state.hidden_contacts().is_empty() { + return action; + } + + ui.add_space(8.0); + let label = format!("{SHOW_HIDDEN_LABEL} · {}", state.hidden_contacts().len()); + ui.checkbox(state.show_hidden_mut(), label); + if !state.show_hidden() { + return action; + } + + ui.add_space(4.0); + ui.label( + RichText::new(HIDDEN_EXPLAINER) + .small() + .color(DashColors::text_secondary(dark_mode)), + ); + ui.add_space(8.0); + + // The clicked contact is cloned out of the list so the unhide can mutate + // `state` once the read borrow the row loop holds has ended. + let mut clicked: Option = None; + for contact in state.hidden_contacts() { + ui.horizontal(|ui| { + ui.label( + RichText::new(contact_label(contact)).color(DashColors::text_secondary(dark_mode)), + ); + ui.with_layout( + eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), + |ui| { + let unhide = ui + .add(ComponentStyles::secondary_button(UNHIDE_LABEL, dark_mode)) + .clickable_tooltip(UNHIDE_TOOLTIP); + if unhide.clicked() { + clicked = Some(contact.clone()); + } + }, + ); + }); + ui.add_space(4.0); + } + + if let Some(contact) = clicked { + // Move the row now; the authoritative reload lands when the broadcast + // confirms. + state.unhide_contact(&contact.identity_id); + action = AppAction::BackendTask(BackendTask::DashPayTask(Box::new(unhide_task( + identity.clone(), + &contact, + )))); + } + + action +} + /// Sent requests — one [`RequestCard`] per pending outgoing request, with /// Cancel wired to [`DashPayTask::CancelContactRequest`]. fn sent_section( @@ -654,6 +749,8 @@ mod tests { NO_SENT_EMPTY, NO_SEARCH_MATCH, CANCEL_EXPLAINER, + HIDDEN_EXPLAINER, + UNHIDE_TOOLTIP, ] { assert!( line.ends_with('.'), @@ -767,6 +864,65 @@ mod tests { ); } + // --------------------------------------------------------------- + // Unhide — the recovery path for a contact that a decline, a cancel, + // or a manual hide took off the list. + // --------------------------------------------------------------- + + fn hidden_contact(nickname: Option<&str>, note: Option<&str>) -> ContactData { + ContactData { + identity_id: id(5), + nickname: nickname.map(str::to_string), + note: note.map(str::to_string), + is_hidden: true, + account_reference: 0, + username: None, + display_name: None, + avatar_url: None, + bio: None, + } + } + + #[test] + fn unhide_clears_the_hidden_flag_for_that_contact() { + let task = unhide_task(qualified_identity(id(1)), &hidden_contact(None, None)); + match task { + DashPayTask::UpdateContactInfo { + identity, + contact_id, + is_hidden, + .. + } => { + assert_eq!(identity.identity.id(), id(1)); + assert_eq!(contact_id, id(5), "the clicked contact must be unhidden"); + assert!( + !is_hidden, + "unhiding must broadcast contactInfo with the hidden flag cleared" + ); + } + other => panic!("Unhide must dispatch UpdateContactInfo, got {other:?}"), + } + } + + #[test] + fn unhide_preserves_the_nickname_and_note() { + let task = unhide_task( + qualified_identity(id(1)), + &hidden_contact(Some("Bao"), Some("Met at the meetup")), + ); + match task { + DashPayTask::UpdateContactInfo { nickname, note, .. } => { + assert_eq!( + nickname.as_deref(), + Some("Bao"), + "restoring visibility must not wipe the contact's nickname" + ); + assert_eq!(note.as_deref(), Some("Met at the meetup")); + } + other => panic!("expected UpdateContactInfo, got {other:?}"), + } + } + #[test] fn every_request_action_maps_to_a_distinct_task() { let tasks: Vec = [ diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 51c98a9f5..655ec5c3d 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -379,6 +379,17 @@ impl ScreenLike for IdentityHubScreen { BackendTaskSuccessResult::DashPayContactRequestCancelled(request_id) => { self.resolve_request(request_id, "Contact request cancelled."); } + // A confirmed contactInfo write — on this tab that is an unhide. + // Re-arm the load so the restored contact comes back from the + // authoritative list, not just the optimistic local move. + BackendTaskSuccessResult::DashPayContactInfoUpdated(_) => { + self.contacts_state.invalidate(); + MessageBanner::set_global( + self.app_context.egui_ctx(), + "This contact is back in your list.", + MessageType::Success, + ); + } // The counterpart answered while the row was on screen. Nothing to // withdraw or accept — reload into the truth. BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { diff --git a/src/ui/state/contacts_view.rs b/src/ui/state/contacts_view.rs index e8bd1335c..6c44bd5b7 100644 --- a/src/ui/state/contacts_view.rs +++ b/src/ui/state/contacts_view.rs @@ -40,8 +40,15 @@ pub struct ContactsState { incoming: Vec, /// Requests this identity has sent and that are still pending. outgoing: Vec, - /// Established contacts, from `DashPayTask::LoadContacts`. + /// Established contacts the user has not hidden. contacts: Vec, + /// Established contacts flagged `display_hidden`, kept aside so the tab can + /// offer a way back. Declining or cancelling a request hides that person, + /// so without this list a contact could only be recovered from the legacy + /// DashPay screen. + hidden: Vec, + /// Whether the hidden-contacts section is expanded. + show_hidden: bool, /// Live search query bound to the Contacts search box. search: String, } @@ -64,6 +71,8 @@ impl ContactsState { self.incoming.clear(); self.outgoing.clear(); self.contacts.clear(); + self.hidden.clear(); + self.show_hidden = false; self.search.clear(); } @@ -126,12 +135,14 @@ impl ContactsState { .collect(); } - /// Store the established-contact list from `DashPayTask::LoadContacts`. - /// Contacts the user has hidden (including ones whose request they declined - /// or cancelled) are dropped — "hidden" is exactly the promise that they do - /// not appear in the list. + /// Store the established-contact list from `DashPayTask::LoadContacts`, + /// split into the visible contacts and the hidden ones. Hidden contacts stay + /// out of the active list — that is what "hidden" promises — but remain + /// reachable through [`ContactsState::hidden_contacts`]. pub fn record_contacts(&mut self, contacts: Vec) { - self.contacts = contacts.into_iter().filter(|c| !c.is_hidden).collect(); + let (hidden, visible) = contacts.into_iter().partition(|c| c.is_hidden); + self.contacts = visible; + self.hidden = hidden; } /// Number of established contacts, before search filtering. Drives the @@ -143,13 +154,44 @@ impl ContactsState { /// Contacts matching the current search query, in list order. An empty or /// whitespace-only query matches every contact. pub fn filtered_contacts(&self) -> Vec<&ContactData> { - let needle = self.search.trim().to_lowercase(); self.contacts .iter() - .filter(|c| matches_search(c, &needle)) + .filter(|c| matches_contact_search((*c).into(), &self.search)) .collect() } + /// Established contacts currently flagged hidden. Never search-filtered: + /// the section exists to make a vanished contact findable, so it always + /// shows all of them. + pub fn hidden_contacts(&self) -> &[ContactData] { + &self.hidden + } + + /// Whether the hidden-contacts section is expanded. + pub fn show_hidden(&self) -> bool { + self.show_hidden + } + + /// Mutable handle on the hidden-section toggle, for binding to a checkbox. + pub fn show_hidden_mut(&mut self) -> &mut bool { + &mut self.show_hidden + } + + /// Move a contact out of the hidden list and back into the active one, so an + /// unhide shows up immediately instead of waiting for the reload. A no-op + /// when the contact is not hidden. + pub fn unhide_contact(&mut self, contact_id: &Identifier) { + if let Some(pos) = self + .hidden + .iter() + .position(|c| c.identity_id == *contact_id) + { + let mut contact = self.hidden.remove(pos); + contact.is_hidden = false; + self.contacts.push(contact); + } + } + /// Drop a resolved request (accepted, declined, or cancelled) from both /// lists so the row leaves the UI immediately, without waiting for the /// authoritative reload to land. @@ -159,27 +201,53 @@ impl ContactsState { } } +/// The handles a contact search matches against, borrowed from whichever contact +/// type the caller holds — the Identity Hub's [`ContactData`] or the legacy +/// DashPay screen's `Contact`. One field set, so both lists find the same +/// contact for the same query. +#[derive(Debug, Clone, Copy)] +pub struct ContactSearchFields<'a> { + pub nickname: Option<&'a str>, + pub display_name: Option<&'a str>, + pub username: Option<&'a str>, + pub bio: Option<&'a str>, + pub identity_id: Identifier, +} + +impl<'a> From<&'a ContactData> for ContactSearchFields<'a> { + fn from(contact: &'a ContactData) -> Self { + Self { + nickname: contact.nickname.as_deref(), + display_name: contact.display_name.as_deref(), + username: contact.username.as_deref(), + bio: contact.bio.as_deref(), + identity_id: contact.identity_id, + } + } +} + /// Case-insensitive substring match over every handle a user might type: -/// nickname, display name, DPNS username, and the Base58 identity ID. An empty -/// needle matches everything. -fn matches_search(contact: &ContactData, needle_lowercase: &str) -> bool { - if needle_lowercase.is_empty() { +/// nickname, display name, DPNS username, bio, and the Base58 identity ID. An +/// empty or whitespace-only query matches every contact. +pub fn matches_contact_search(fields: ContactSearchFields<'_>, query: &str) -> bool { + let needle = query.trim().to_lowercase(); + if needle.is_empty() { return true; } - let haystacks = [ - contact.nickname.as_deref(), - contact.display_name.as_deref(), - contact.username.as_deref(), - ]; - haystacks - .iter() - .flatten() - .any(|field| field.to_lowercase().contains(needle_lowercase)) - || contact + [ + fields.nickname, + fields.display_name, + fields.username, + fields.bio, + ] + .iter() + .flatten() + .any(|field| field.to_lowercase().contains(&needle)) + || fields .identity_id .to_string(Encoding::Base58) .to_lowercase() - .contains(needle_lowercase) + .contains(&needle) } /// Best label for a contact row: local nickname, then DashPay display name, @@ -279,17 +347,27 @@ mod tests { assert!(state.search_mut().is_empty(), "reset must clear the search"); } + /// A hidden contact with a distinct identity, so it can be unhidden by ID. + fn hidden_contact(nickname: &str, identity_id: Identifier) -> ContactData { + ContactData { + identity_id, + is_hidden: true, + ..contact(Some(nickname), None, None) + } + } + #[test] - fn record_contacts_drops_hidden_contacts() { + fn record_contacts_keeps_hidden_contacts_out_of_the_active_list() { let mut state = ContactsState::default(); - let mut hidden = contact(Some("Ghost"), None, None); - hidden.is_hidden = true; - state.record_contacts(vec![contact(Some("Bao"), None, None), hidden]); + state.record_contacts(vec![ + contact(Some("Bao"), None, None), + hidden_contact("Ghost", id(8)), + ]); assert_eq!( state.contacts_len(), 1, - "hidden contacts must not be listed" + "hidden contacts must not be listed among the active ones" ); assert_eq!( state.filtered_contacts()[0].nickname.as_deref(), @@ -297,6 +375,100 @@ mod tests { ); } + #[test] + fn hidden_contacts_stay_reachable_so_a_contact_never_vanishes() { + let mut state = ContactsState::default(); + state.record_contacts(vec![ + contact(Some("Bao"), None, None), + hidden_contact("Ghost", id(8)), + ]); + + let hidden = state.hidden_contacts(); + assert_eq!(hidden.len(), 1, "a hidden contact must remain recoverable"); + assert_eq!(hidden[0].nickname.as_deref(), Some("Ghost")); + } + + #[test] + fn the_hidden_section_is_collapsed_until_the_user_opens_it() { + let mut state = ContactsState::default(); + assert!( + !state.show_hidden(), + "hidden contacts stay hidden by default" + ); + + *state.show_hidden_mut() = true; + assert!(state.show_hidden()); + + state.reset(); + assert!( + !state.show_hidden(), + "leaving the tab must collapse the section again" + ); + } + + #[test] + fn unhiding_a_contact_moves_it_into_the_active_list() { + let mut state = ContactsState::default(); + state.record_contacts(vec![ + contact(Some("Bao"), None, None), + hidden_contact("Ghost", id(8)), + ]); + + state.unhide_contact(&id(8)); + + assert!( + state.hidden_contacts().is_empty(), + "the unhidden contact must leave the hidden list" + ); + assert_eq!( + state.contacts_len(), + 2, + "the unhidden contact must join the active list without waiting for a reload" + ); + let unhidden = state + .filtered_contacts() + .into_iter() + .find(|c| c.identity_id == id(8)) + .expect("the unhidden contact is now active"); + assert!( + !unhidden.is_hidden, + "the moved contact must no longer be flagged hidden" + ); + } + + #[test] + fn unhiding_an_unknown_contact_changes_nothing() { + let mut state = ContactsState::default(); + state.record_contacts(vec![hidden_contact("Ghost", id(8))]); + + state.unhide_contact(&id(3)); + + assert_eq!(state.hidden_contacts().len(), 1); + assert_eq!(state.contacts_len(), 0); + } + + #[test] + fn reset_clears_the_hidden_list() { + let mut state = ContactsState::default(); + state.record_contacts(vec![hidden_contact("Ghost", id(8))]); + + state.reset(); + + assert!(state.hidden_contacts().is_empty()); + } + + #[test] + fn search_matches_the_same_fields_for_every_contact_list() { + // One matcher, one field set — the hub and the legacy screen must not + // disagree about whether a query finds a contact. + let mut with_bio = contact(None, None, None); + with_bio.bio = Some("Loves kayaking".to_string()); + + assert!(matches_contact_search((&with_bio).into(), "kayak")); + assert!(matches_contact_search((&with_bio).into(), " ")); + assert!(!matches_contact_search((&with_bio).into(), "surfing")); + } + #[test] fn empty_search_matches_every_contact() { let mut state = ContactsState::default(); From e6057db4b91f89946a5f63cb3c1c44b03ecdcaa8 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:20:50 +0000 Subject: [PATCH 12/30] docs(user-stories): record the hidden-contact recovery path in DPY-009 Co-Authored-By: Claude Opus 4.8 --- docs/user-stories.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/user-stories.md b/docs/user-stories.md index 1952da778..ad8cf07d4 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -753,6 +753,9 @@ As a user, I want to edit contact details (nickname, note, hidden status) so tha - Set custom nickname and personal notes per contact. - Toggle contact visibility (hidden/visible). +- Hidden contacts stay listed in a collapsed "Show hidden contacts" section of the Identity Hub + Contacts tab, and can be unhidden from there — including contacts hidden as a side effect of + declining or cancelling a request. - Changes persist locally. ### DPY-010: Remove a contact [Gap] From 273c042cd7ff2fc8b6bf55251adb2ae093551297 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:21:16 +0000 Subject: [PATCH 13/30] fix(ui,tokens): re-disable single-key send, ungate DashPay pay buttons, type the token-dismissal seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/tokens/mod.rs | 17 +- .../tokens/query_my_token_balances.rs | 53 ++-- src/context/contract_token_db.rs | 87 ++++--- src/ui/dashpay/contact_details.rs | 5 +- src/ui/dashpay/contact_profile_viewer.rs | 6 +- src/ui/dashpay/contacts_list.rs | 5 +- src/ui/mod.rs | 4 + src/ui/wallets/single_key_send_screen.rs | 226 ++++++++++++------ 8 files changed, 257 insertions(+), 146 deletions(-) diff --git a/src/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index 8c34e4c87..1ada2be23 100644 --- a/src/backend_task/tokens/mod.rs +++ b/src/backend_task/tokens/mod.rs @@ -483,21 +483,12 @@ impl AppContext { .await } TokenTask::QueryIdentityTokenBalance(identity_token_pair) => { - self.query_token_balance( - sdk, - identity_token_pair.identity_id, - identity_token_pair.token_id, - sender, - ) - .await + self.query_token_balance(sdk, identity_token_pair, sender) + .await } TokenTask::StopTrackingTokenBalance(identity_token_pair) => { - self.stop_tracking_token_balance( - identity_token_pair.identity_id, - identity_token_pair.token_id, - sender, - ) - .await + self.stop_tracking_token_balance(identity_token_pair, sender) + .await } TokenTask::FetchTokenByContractId(contract_id) => { match DataContract::fetch_by_identifier(sdk, contract_id).await { diff --git a/src/backend_task/tokens/query_my_token_balances.rs b/src/backend_task/tokens/query_my_token_balances.rs index a1936eb04..199416a23 100644 --- a/src/backend_task/tokens/query_my_token_balances.rs +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -12,6 +12,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::ui::tokens::tokens_screen::IdentityTokenIdentifier; use dash_sdk::Sdk; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::platform::Identifier; @@ -42,42 +43,40 @@ impl AppContext { pub async fn query_token_balance( &self, _sdk: &Sdk, - identity_id: Identifier, - token_id: Identifier, + pair: IdentityTokenIdentifier, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { // Asking for a balance is intent to track it: undo any earlier "stop // tracking" for this pair, otherwise the watch set would omit the very // token the caller asked about. - self.clear_untracked_token_balance(token_id, identity_id)?; + self.clear_untracked_token_balance(pair)?; // The upstream watch list is per-identity and replaced wholesale, so // register the identity's whole watch set rather than a single pair. - let watch_sets = self.token_watch_sets(vec![identity_id])?; + let watch_sets = self.token_watch_sets(vec![pair.identity_id])?; self.refresh_upstream_token_balances(watch_sets, &sender) .await?; Ok(BackendTaskSuccessResult::FetchedTokenBalances) } - /// Stop tracking one `(identity, token)` balance. Un-watches the pair in - /// the upstream sync loop so its background pass stops fetching the - /// balance and the pair leaves the published snapshot, records the - /// dismissal so later refreshes do not re-watch it, then drops it from the - /// saved My Tokens ordering and nudges the UI to re-read the snapshot. The - /// token stays in DET's registry: re-importing it, or explicitly checking - /// that identity's balance, tracks the pair again. + /// Stop tracking one identity-token balance. Un-watches the pair in the + /// upstream sync loop so its background pass stops fetching the balance and + /// the pair leaves the published snapshot, records the dismissal so later + /// refreshes do not re-watch it, then drops it from the saved My Tokens + /// ordering and nudges the UI to re-read the snapshot. The token stays in + /// DET's registry: re-importing it, or explicitly checking that identity's + /// balance, tracks the pair again. pub async fn stop_tracking_token_balance( &self, - identity_id: Identifier, - token_id: Identifier, + pair: IdentityTokenIdentifier, sender: crate::utils::egui_mpsc::SenderAsync, ) -> Result { self.wallet_backend()? - .unwatch_identity_token(identity_id, token_id) + .unwatch_identity_token(pair.identity_id, pair.token_id) .await; - self.mark_token_balance_untracked(token_id, identity_id)?; - self.remove_token_balance(token_id, identity_id)?; + self.mark_token_balance_untracked(pair)?; + self.remove_token_balance(pair)?; sender .send(TaskResult::Refresh) .await @@ -103,7 +102,12 @@ impl AppContext { let watched = token_ids .iter() .copied() - .filter(|token_id| !untracked.contains(&(*token_id, identity_id))) + .filter(|token_id| { + !untracked.contains(&IdentityTokenIdentifier { + identity_id, + token_id: *token_id, + }) + }) .collect(); (identity_id, watched) }) @@ -224,6 +228,13 @@ mod tests { (alpha, beta) } + fn pair(identity_id: Identifier, token_id: Identifier) -> IdentityTokenIdentifier { + IdentityTokenIdentifier { + identity_id, + token_id, + } + } + /// Dismissing a balance must survive "Refresh My Tokens": the pair stays /// out of the identity's watch set, and only that identity is affected. #[tokio::test] @@ -233,7 +244,7 @@ mod tests { let (identity, other_identity) = (ident(10), ident(11)); f.ctx - .stop_tracking_token_balance(identity, alpha, f.sender.clone()) + .stop_tracking_token_balance(pair(identity, alpha), f.sender.clone()) .await .expect("stop tracking"); @@ -249,11 +260,11 @@ mod tests { let identity = ident(10); f.ctx - .stop_tracking_token_balance(identity, alpha, f.sender.clone()) + .stop_tracking_token_balance(pair(identity, alpha), f.sender.clone()) .await .expect("stop tracking"); f.ctx - .clear_untracked_token_balance(alpha, identity) + .clear_untracked_token_balance(pair(identity, alpha)) .expect("re-track pair"); assert_eq!(watched(&f.ctx, identity), vec![alpha, beta]); @@ -269,7 +280,7 @@ mod tests { for identity in [first, second] { f.ctx - .stop_tracking_token_balance(identity, alpha, f.sender.clone()) + .stop_tracking_token_balance(pair(identity, alpha), f.sender.clone()) .await .expect("stop tracking"); } diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 64c88e45f..3965266a2 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -380,52 +380,51 @@ impl AppContext { Ok(result) } - /// Drop a single `(identity, token)` pair from the My Tokens ordering. + /// Drop a single identity-token pair from the My Tokens ordering. /// Balances are owned upstream, so this only prunes DET's saved order /// list — see [`Self::mark_token_balance_untracked`] for the dismissal /// that keeps the pair out of future watch sets. pub fn remove_token_balance( &self, - token_id: Identifier, - identity_id: Identifier, + pair: IdentityTokenIdentifier, ) -> std::result::Result<(), TaskError> { let kv = self.det_kv()?; - prune_token_order(&kv, |(t, i)| !(*t == token_id && *i == identity_id))?; + prune_token_order(&kv, |(t, i)| { + !(*t == pair.token_id && *i == pair.identity_id) + })?; Ok(()) } - /// Every `(token_id, identity_id)` pair the user stopped tracking. + /// Every identity-token pair the user stopped tracking. pub fn untracked_token_balances( &self, - ) -> std::result::Result, TaskError> { + ) -> std::result::Result, TaskError> { read_untracked(&self.det_kv()?) } - /// Record a `(token, identity)` pair as no longer tracked, so balance + /// Record an identity-token pair as no longer tracked, so balance /// refreshes stop re-watching it upstream. Idempotent. pub fn mark_token_balance_untracked( &self, - token_id: Identifier, - identity_id: Identifier, + pair: IdentityTokenIdentifier, ) -> std::result::Result<(), TaskError> { let kv = self.det_kv()?; let mut pairs = read_untracked(&kv)?; - if pairs.insert((token_id, identity_id)) { + if pairs.insert(pair) { write_untracked(&kv, &pairs)?; } Ok(()) } - /// Track a previously dismissed `(token, identity)` pair again. + /// Track a previously dismissed identity-token pair again. /// Idempotent — a pair that was never dismissed is left alone. pub fn clear_untracked_token_balance( &self, - token_id: Identifier, - identity_id: Identifier, + pair: IdentityTokenIdentifier, ) -> std::result::Result<(), TaskError> { let kv = self.det_kv()?; let mut pairs = read_untracked(&kv)?; - if pairs.remove(&(token_id, identity_id)) { + if pairs.remove(&pair) { write_untracked(&kv, &pairs)?; } Ok(()) @@ -713,11 +712,11 @@ fn decode_token_config(bytes: &[u8]) -> std::result::Result std::result::Result, TaskError> { +/// Read the dismissed pairs. An absent key means nothing was ever dismissed. +/// The stored payload is `(token_id, identity_id)`-ordered; that on-disk layout +/// is fixed by already-written user data, so the mapping to the named fields +/// happens here and nowhere else. +fn read_untracked(kv: &DetKv) -> std::result::Result, TaskError> { let Some(payload): Option> = kv .get(DetScope::Global, TOKEN_UNTRACKED_KEY) .map_err(token_err)? @@ -726,17 +725,20 @@ fn read_untracked( }; Ok(payload .into_iter() - .map(|(t, i)| (Identifier::from(t), Identifier::from(i))) + .map(|(token, identity)| IdentityTokenIdentifier { + token_id: Identifier::from(token), + identity_id: Identifier::from(identity), + }) .collect()) } fn write_untracked( kv: &DetKv, - pairs: &BTreeSet<(Identifier, Identifier)>, + pairs: &BTreeSet, ) -> std::result::Result<(), TaskError> { let payload: Vec<([u8; 32], [u8; 32])> = pairs .iter() - .map(|(t, i)| (t.to_buffer(), i.to_buffer())) + .map(|pair| (pair.token_id.to_buffer(), pair.identity_id.to_buffer())) .collect(); kv.put(DetScope::Global, TOKEN_UNTRACKED_KEY, &payload) .map_err(token_err) @@ -750,7 +752,7 @@ fn clear_untracked_token_in( ) -> std::result::Result<(), TaskError> { let mut pairs = read_untracked(kv)?; let before = pairs.len(); - pairs.retain(|(t, _)| t != token_id); + pairs.retain(|pair| pair.token_id != *token_id); if pairs.len() != before { write_untracked(kv, &pairs)?; } @@ -921,6 +923,13 @@ mod tests { // Untracked pairs: dismissals persist so refreshes stop re-watching. // ---------------------------------------------------------------- + fn pair(token: u8, identity: u8) -> IdentityTokenIdentifier { + IdentityTokenIdentifier { + token_id: ident(token), + identity_id: ident(identity), + } + } + #[test] fn untracked_pairs_read_as_empty_before_anything_is_dismissed() { let kv = empty_kv(); @@ -930,21 +939,37 @@ mod tests { #[test] fn untracked_pairs_round_trip_through_the_kv_store() { let kv = empty_kv(); - let pairs = BTreeSet::from([(ident(1), ident(10)), (ident(2), ident(20))]); + let pairs = BTreeSet::from([pair(1, 10), pair(2, 20)]); write_untracked(&kv, &pairs).unwrap(); assert_eq!(read_untracked(&kv).unwrap(), pairs); } + /// The stored layout is `(token_id, identity_id)` and is fixed by + /// already-written user data: reading it back as the wrong field would + /// silently un-track a pair nobody dismissed. + #[test] + fn untracked_pairs_persist_in_token_then_identity_order() { + let kv = empty_kv(); + write_untracked(&kv, &BTreeSet::from([pair(1, 10)])).unwrap(); + + let payload: Vec<([u8; 32], [u8; 32])> = kv + .get(DetScope::Global, TOKEN_UNTRACKED_KEY) + .unwrap() + .unwrap(); + + assert_eq!( + payload, + vec![(ident(1).to_buffer(), ident(10).to_buffer())], + "token_id is stored first, identity_id second" + ); + } + #[test] fn clearing_a_token_drops_its_dismissals_for_every_identity() { let kv = empty_kv(); let cleared = ident(1); - let kept = (ident(2), ident(10)); - write_untracked( - &kv, - &BTreeSet::from([(cleared, ident(10)), (cleared, ident(11)), kept]), - ) - .unwrap(); + let kept = pair(2, 10); + write_untracked(&kv, &BTreeSet::from([pair(1, 10), pair(1, 11), kept])).unwrap(); clear_untracked_token_in(&kv, &cleared).unwrap(); @@ -954,7 +979,7 @@ mod tests { #[test] fn clearing_an_undismissed_token_is_a_noop() { let kv = empty_kv(); - let pairs = BTreeSet::from([(ident(1), ident(10))]); + let pairs = BTreeSet::from([pair(1, 10)]); write_untracked(&kv, &pairs).unwrap(); clear_untracked_token_in(&kv, &ident(9)).unwrap(); assert_eq!(read_untracked(&kv).unwrap(), pairs); diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index 6aeba5730..cbaf2d31a 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -294,10 +294,7 @@ impl ContactDetailsScreen { }); ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| { - // Send Payment requires SPV which is dev mode only - if self.app_context.is_developer_mode() - && ui.button("Send Payment").clicked() - { + if ui.button("Send Payment").clicked() { action = AppAction::AddScreen( ScreenType::DashPaySendPayment( self.identity.clone(), diff --git a/src/ui/dashpay/contact_profile_viewer.rs b/src/ui/dashpay/contact_profile_viewer.rs index 9dcacbf5c..4d408432e 100644 --- a/src/ui/dashpay/contact_profile_viewer.rs +++ b/src/ui/dashpay/contact_profile_viewer.rs @@ -3,7 +3,6 @@ use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; -use crate::context::feature_gate::FeatureGate; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::avatar::Avatar; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; @@ -120,11 +119,8 @@ impl ContactProfileViewerScreen { AppAction::BackendTask(task) } - /// Render a "Pay" button gated behind developer mode. + /// Render a "Pay" button. fn pay_button(&self, ui: &mut egui::Ui) -> AppAction { - if !FeatureGate::DeveloperMode.is_available(&self.app_context) { - return AppAction::None; - } let pay_button = egui::Button::new(RichText::new("Pay").color(egui::Color32::WHITE)) .fill(egui::Color32::from_rgb(0, 141, 228)); // Dash blue if ui.add(pay_button).clicked() { diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 0c5ee3efa..282efaab1 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -839,10 +839,7 @@ impl ContactsList { } } - // Pay button - requires SPV which is dev mode only - if self.app_context.is_developer_mode() - && ui.button("Pay").clicked() - { + if ui.button("Pay").clicked() { if let Some(identity) = self.selected_identity.clone() { action = AppAction::AddScreen( ScreenType::DashPaySendPayment( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index cabf7cb55..bcd89428f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -213,6 +213,10 @@ pub enum ScreenType { DashPayAddContactWithId(String), // Pre-populated identity ID DashPayContactDetails(QualifiedIdentity, Identifier), DashPayContactProfileViewer(QualifiedIdentity, Identifier), + /// Reached from the Identity Hub, the contacts list, contact details and the + /// profile viewer — all four ungated. TODO: classify these entry points + /// explicitly when the `UserRole`/`FeatureGate` rework (#879) lands, so a + /// rebase cannot silently default them open or closed. DashPaySendPayment(QualifiedIdentity, Identifier), DashPayQRGenerator, DashPayProfileSearch, diff --git a/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 58cc98381..5766f7ad0 100644 --- a/src/ui/wallets/single_key_send_screen.rs +++ b/src/ui/wallets/single_key_send_screen.rs @@ -71,11 +71,10 @@ pub struct SingleKeyWalletSendScreen { // Advanced options toggle show_advanced_options: bool, - /// Persistent warning banner rendered when the app is running on the SPV - /// backend. Stored on the screen (rather than constructed fresh each - /// frame) so the underlying tracing log fires once on mode entry instead - /// of every repaint. - spv_warning_banner: MessageBanner, + /// States the single-key send limitation up front. Stored on the screen + /// (rather than constructed fresh each frame) so the underlying tracing log + /// fires once on entry instead of every repaint. + send_unavailable_banner: MessageBanner, } impl SingleKeyWalletSendScreen { @@ -89,7 +88,7 @@ impl SingleKeyWalletSendScreen { password_input: PasswordInput::new().with_hint_text("Enter password"), fee_dialog: FeeConfirmationDialog::default(), show_advanced_options: false, - spv_warning_banner: MessageBanner::new(), + send_unavailable_banner: MessageBanner::new(), } } @@ -267,13 +266,21 @@ impl SingleKeyWalletSendScreen { self.fee_dialog.estimated_fee = estimated_fee; } + Ok(self.dispatch_send(wallet.clone(), request)) + } + + /// The single dispatch point for a send: arms the busy flag as it hands the + /// task off, so the flag cannot get out of step with what is in flight. + /// Every terminal result clears it again (see `display_message`). + fn dispatch_send( + &mut self, + wallet: Arc>, + request: WalletPaymentRequest, + ) -> AppAction { self.sending = true; - Ok(AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::SendSingleKeyWalletPayment { - wallet: wallet.clone(), - request, - }, - ))) + AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendSingleKeyWalletPayment { wallet, request }, + )) } fn render_recipients(&mut self, ui: &mut Ui) { @@ -607,7 +614,6 @@ impl SingleKeyWalletSendScreen { if ComponentStyles::add_secondary_button(ui, "Cancel", dark_mode).clicked() { self.fee_dialog.is_open = false; self.fee_dialog.pending_request = None; - self.sending = false; } ui.add_space(20.0); @@ -617,13 +623,8 @@ impl SingleKeyWalletSendScreen { // Update the request to use the higher fee request.override_fee = Some(self.fee_dialog.required_fee); - if let Some(wallet) = &self.selected_wallet { - action = AppAction::BackendTask(BackendTask::CoreTask( - CoreTask::SendSingleKeyWalletPayment { - wallet: wallet.clone(), - request, - }, - )); + if let Some(wallet) = self.selected_wallet.clone() { + action = self.dispatch_send(wallet, request); } } self.fee_dialog.is_open = false; @@ -778,38 +779,18 @@ impl SingleKeyWalletSendScreen { ui.add_space(20.0); - // Send button. Enabled purely by the local preconditions (wallet - // unlocked, no send in flight) — the version limitation is not a UI - // gate: `CoreTask::SendSingleKeyWalletPayment` is the authoritative - // enforcement layer and refuses with a typed - // `TaskError::SingleKeyWalletsUnsupported`, which surfaces as an - // error banner. The warning banner above states the limitation - // before the user commits to the attempt. - let wallet_is_open = self - .selected_wallet - .as_ref() - .is_some_and(|w| w.read().map(|g| g.is_open()).unwrap_or(false)); - - let button_enabled = wallet_is_open && !self.sending; - // Only force white label text when the button is actually clickable; - // otherwise let egui's default disabled visuals take over so the - // greyed-out state is visually unambiguous. + // `CoreTask::SendSingleKeyWalletPayment` refuses every single-key + // send with `TaskError::SingleKeyWalletsUnsupported`, so the button + // stays disabled until that task can build, sign and broadcast a + // transaction. Mirrors the disabled Send in the wallets action bar; + // left unstyled so egui's default disabled visuals apply. let send_label = RichText::new(if self.sending { "Sending..." } else { "Send" }).strong(); - let send_label = if button_enabled { - send_label.color(Color32::WHITE) - } else { - send_label - }; - let send_button = egui::Button::new(send_label) - .fill(if button_enabled { - DashColors::DASH_BLUE - } else { - DashColors::DASH_BLUE.gamma_multiply(0.5) - }) - .min_size(egui::vec2(120.0, 36.0)); + let send_button = egui::Button::new(send_label).min_size(egui::vec2(120.0, 36.0)); - let response = ui.add_enabled(button_enabled, send_button); + let response = ui + .add_enabled(false, send_button) + .on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE); if response.clicked() { match self.validate_and_send() { Ok(send_action) => { @@ -852,17 +833,14 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { - // States the version limitation up front, so a send attempt - // that the backend will refuse is never a surprise. Stored on - // the screen so the underlying tracing log fires once on - // entry instead of every repaint — see the matching note in - // `single_key_view.rs`. - if !self.spv_warning_banner.has_message() { - self.spv_warning_banner + // States the limitation up front, so the disabled Send below + // is never a surprise. + if !self.send_unavailable_banner.has_message() { + self.send_unavailable_banner .set_message(SINGLE_KEY_SEND_UNAVAILABLE, MessageType::Warning) .disable_auto_dismiss(); } - self.spv_warning_banner.show(ui); + self.send_unavailable_banner.show(ui); ui.add_space(10.0); // Heading with Advanced Options checkbox @@ -918,23 +896,22 @@ impl ScreenLike for SingleKeyWalletSendScreen { } fn display_message(&mut self, message: &str, message_type: MessageType) { - // Error/success display is handled by the global MessageBanner. - // Only side-effects are preserved here. - - // Check for success messages to reset sending state - if message.contains("Sent") || message.contains("TxID") { - self.sending = false; - self.fee_dialog.pending_request = None; - } + // Banner display is handled globally by AppState; this is only for + // side-effects. Always clear sending — the task that armed it is done, + // whatever it returned. A send refused by the backend (every single-key + // send is, today) must not strand the button on "Sending...". + self.sending = false; - // Check for min relay fee error and show confirmation dialog if matches!(message_type, MessageType::Error | MessageType::Warning) && let Some(required_fee) = Self::parse_min_relay_fee_error(message) { - // Show the fee confirmation dialog instead of the error message + // The fee is the only recoverable send error: offer the higher fee + // instead of the raw message. Confirming re-dispatches, which arms + // the busy flag again. self.fee_dialog.required_fee = required_fee; self.fee_dialog.is_open = true; - // Keep sending state true until user confirms or cancels + } else { + self.fee_dialog.pending_request = None; } } @@ -989,3 +966,116 @@ impl ScreenLike for SingleKeyWalletSendScreen { fn refresh(&mut self) {} } + +#[cfg(test)] +mod tests { + use super::*; + use crate::context::connection_status::ConnectionStatus; + use crate::database::test_helpers::create_database_at_path; + use crate::utils::tasks::TaskManager; + use dash_sdk::dpp::dashcore::Network; + + /// Build an offline `AppContext` (no network I/O, throwaway data dir). + fn offline_ctx() -> (Arc, tempfile::TempDir) { + use crate::app_dir::ensure_env_file; + + let temp_dir = tempfile::tempdir().expect("tempdir"); + let data_dir = temp_dir.path().to_path_buf(); + ensure_env_file(&data_dir); + let db = Arc::new(create_database_at_path(&data_dir.join("data.db")).expect("db")); + let app_kv = AppContext::open_app_kv(&data_dir).expect("app kv"); + let secret_store = AppContext::open_secret_store(&data_dir).expect("secret store"); + let ctx = AppContext::new( + data_dir, + Network::Testnet, + db, + Arc::new(TaskManager::new()), + Arc::new(ConnectionStatus::new()), + egui::Context::default(), + app_kv, + secret_store, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("offline testnet AppContext::new"); + (ctx, temp_dir) + } + + fn send_screen() -> (SingleKeyWalletSendScreen, tempfile::TempDir) { + let (ctx, temp_dir) = offline_ctx(); + let wallet = + SingleKeyWallet::new([1u8; 32], Network::Testnet, None, None).expect("single key"); + let screen = SingleKeyWalletSendScreen::new(&ctx, Arc::new(RwLock::new(wallet))); + (screen, temp_dir) + } + + /// The busy flag means "a send is in flight". Every single-key send is + /// refused by the backend with `SingleKeyWalletsUnsupported`, so a flag + /// that only clears on success-shaped text would strand the button on + /// "Sending..." forever. + #[test] + fn busy_flag_clears_on_the_refusal_every_single_key_send_produces() { + let (mut screen, _tmp) = send_screen(); + screen.sending = true; + + screen.display_message( + &TaskError::SingleKeyWalletsUnsupported.to_string(), + MessageType::Error, + ); + + assert!( + !screen.sending, + "a refused send must not leave the screen stuck busy" + ); + } + + /// The busy flag is armed by the dispatch itself, so a new call site cannot + /// hand a task off without it. + #[test] + fn dispatching_a_send_arms_the_busy_flag() { + let (mut screen, _tmp) = send_screen(); + let wallet = screen.selected_wallet.clone().expect("wallet"); + let request = WalletPaymentRequest { + recipients: vec![PaymentRecipient { + address: "yWxJqW5Kt1bnJoLtvxDrTBcpqhFuBCVFEK".to_string(), + amount_duffs: 100_000, + }], + override_fee: None, + }; + + let action = screen.dispatch_send(wallet, request); + + assert!(screen.sending, "dispatch must arm the busy flag"); + assert!(matches!( + action, + AppAction::BackendTask(BackendTask::CoreTask( + CoreTask::SendSingleKeyWalletPayment { .. } + )) + )); + } + + /// The min-relay-fee dialog is the one recoverable send error: it keeps the + /// stashed request so "Confirm & Send" can re-dispatch at the higher fee, + /// but no task is in flight while the user decides. + #[test] + fn min_relay_fee_error_offers_the_retry_and_ends_the_in_flight_send() { + let (mut screen, _tmp) = send_screen(); + screen.sending = true; + screen.fee_dialog.pending_request = Some(WalletPaymentRequest { + recipients: vec![], + override_fee: None, + }); + + screen.display_message("min relay fee not met, 226 < 1000", MessageType::Error); + + assert!( + !screen.sending, + "no task is in flight while the dialog waits" + ); + assert!(screen.fee_dialog.is_open, "the retry dialog must open"); + assert_eq!(screen.fee_dialog.required_fee, 1000); + assert!( + screen.fee_dialog.pending_request.is_some(), + "the stashed request must survive for the higher-fee retry" + ); + } +} From 167b5ffa0f18dd15b3483ea85c93c6971f3689b0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:22:03 +0000 Subject: [PATCH 14/30] 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 --- src/ui/wallets/shielded_tab.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 1eb41ac1b..3e1b91bb0 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -93,7 +93,7 @@ pub struct ShieldedTabView { is_initialized: bool, /// Whether the commitment tree has been synced (enables spend operations). tree_synced: bool, - /// Pending backend task to dispatch on next ui() call (e.g., sync after Resync). + /// Pending backend task to dispatch on next ui() call. pending_task: Option, /// The wallet's shielded receive address (Bech32m), mirrored each frame from /// the frame-safe [`AppContext`] snapshot. `None` until the wallet's Orchard @@ -173,7 +173,7 @@ impl ShieldedTabView { self.app_context = app_context.clone(); } - /// Drain pending backend tasks (from explicit user actions like Resync). + /// Drain pending backend tasks queued by user actions on this tab. /// Initialization is handled entirely by the backend in /// `handle_wallet_unlocked` — the UI never triggers it. pub fn tick(&mut self) -> AppAction { From 74d2af814fedc7f7c0f765b45110ed4891ac804b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 08:22:10 +0000 Subject: [PATCH 15/30] fix(migration): stop a corrupt vote row from blocking the wallet drain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- docs/user-stories.md | 2 +- src/app.rs | 13 + src/app/reconcilers.rs | 15 +- src/backend_task/migration/finish_unwire.rs | 441 +++++++++++++++----- src/context/migration_status.rs | 10 + src/context/wallet_lifecycle/tests.rs | 16 +- src/database/test_helpers.rs | 71 ++++ src/ui/wallets/shielded_tab.rs | 14 +- tests/kittest/migration_banner.rs | 35 +- 9 files changed, 492 insertions(+), 125 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 1952da778..d507b0e4a 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -679,7 +679,7 @@ As a user, I want to assign a friendly alias to an identity behind a username I As a masternode operator, I want my previously scheduled DPNS votes to survive an app upgrade so that I do not miss a contest's vote window after updating. - Scheduled votes stored before the upgrade remain visible and executable afterward. -- The first launch after the upgrade imports them from the previous version's storage, keeping each vote's choice, timestamp, and already-cast state. A vote that cannot be read is reported in a banner rather than dropped silently. +- The first launch after the upgrade imports them from the previous version's storage, keeping each vote's choice, timestamp, and already-cast state. A vote that cannot be read is reported in a banner, with the recovery action, rather than dropped silently — and never blocks the wallet migration that restores access to funds. --- diff --git a/src/app.rs b/src/app.rs index 8eeb4a127..913c6be16 100644 --- a/src/app.rs +++ b/src/app.rs @@ -136,6 +136,19 @@ pub fn migration_running_text(step: MigrationStep) -> &'static str { } } +/// User-facing banner copy for a migration that finished the wallet drain but +/// left `count` undecodable scheduled votes behind. The votes stay in the +/// previous version's storage (nothing is deleted), but they will not be cast, +/// so the sentence names the one action that recovers them. No "Retry now" — +/// a corrupt row decodes no better on a second pass. Exposed for kittest +/// coverage. +pub fn migration_unreadable_votes_text(count: u32) -> String { + format!( + "Some scheduled votes from the previous version could not be read and were not carried \ + over ({count} in total). Schedule them again on the Scheduled Votes screen." + ) +} + /// How long the cold-start readiness gate waits for the wallet backend to wire /// before it stops retrying silently and surfaces a visible, actionable banner. /// diff --git a/src/app/reconcilers.rs b/src/app/reconcilers.rs index f3dbab683..6ebec49f1 100644 --- a/src/app/reconcilers.rs +++ b/src/app/reconcilers.rs @@ -33,7 +33,7 @@ use super::{ COLD_START_BACKEND_READY_TIMEOUT, COLD_START_STUCK_MESSAGE, MIGRATION_RETRY_ACTION_ID, SPV_CONNECTING_DESCRIPTION, SPV_CONTINUE_BACKGROUND_ACTION, SPV_SYNCING_DESCRIPTION, SpvBlockStep, cold_start_backend_wait_timed_out, migration_running_text, - should_dispatch_cold_start, spv_block_step, + migration_unreadable_votes_text, should_dispatch_cold_start, spv_block_step, }; /// Drives platform-level accessibility (AccessKit) activation on the first @@ -474,6 +474,19 @@ impl MigrationReconciler { ); self.banner_handle = Some(handle); } + MigrationState::SucceededWithUnreadableVotes { count } => { + // The wallets landed; only the corrupt vote rows did not. A + // Warning (not Error) with no retry action: the drain is done and + // re-reading a corrupt row cannot help. Sticky, so the user who + // stepped away still learns the votes need re-scheduling. + let handle = MessageBanner::set_global( + ctx, + migration_unreadable_votes_text(count), + MessageType::Warning, + ); + handle.disable_auto_dismiss(); + self.banner_handle = Some(handle); + } MigrationState::Failed { error } => { if error.is_backend_not_ready() { // Transient: the wallet backend had not finished wiring when diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index a20d17505..d9d6b06d2 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -156,23 +156,10 @@ pub enum MigrationError { failed: u32, }, - /// At least one legacy `scheduled_votes` row could not be decoded into a - /// usable vote. Fatal: a scheduled vote that disappears without a word is - /// a missed vote window, so the sentinel stays unwritten and the failure - /// reaches the user's banner. The legacy row is never deleted, so a fixed - /// build can still recover it. - #[error("could not import {unreadable} scheduled vote(s) from the previous version")] - ScheduledVotesUnreadable { - /// Votes that were imported into the k/v store on this pass. - imported: u32, - /// Votes that could not be decoded (corrupt voter id or vote choice). - unreadable: u32, - }, - /// The decoded scheduled votes could not be written into the k/v store. - /// Fatal for the same reason as [`Self::ScheduledVotesUnreadable`] — the - /// sentinel stays unwritten so the next launch retries the idempotent - /// import rather than leaving the votes behind. + /// The app-data sentinel stays unwritten so the next launch retries the + /// idempotent import rather than leaving the votes behind. It never blocks + /// the wallet drain — [`run`] judges this only after the wallets are safe. #[error("could not save scheduled votes from the previous version")] ScheduledVotesWrite { #[source] @@ -236,21 +223,37 @@ impl MigrationError { } /// Run the FinishUnwire migration. Idempotent — completes a no-op when -/// the sentinel is already present. +/// the sentinels are already present. /// /// Returns `true` when this launch actually moved legacy data (rows were -/// detected and drained), and `false` for the two no-op paths: the -/// sentinel already existed, or no legacy rows were present. Callers use -/// the flag to decide whether to surface a "storage update complete" -/// banner — a no-op launch must not show one. +/// detected and drained), and `false` for the no-op paths: the sentinels +/// already existed, or no legacy rows were present. Callers use the flag to +/// decide whether to surface a "storage update complete" banner — a no-op +/// launch must not show one. +/// +/// Two independent passes, in this order: +/// +/// 1. **App data** (scheduled votes, top-up history) — DET-owned rows the +/// wallet drain never touched, under their own sentinel. +/// 2. **Wallet drain** (single keys, HD seeds, wallet metadata, upstream +/// registration) — the pass that restores access to funds. +/// +/// The wallet drain runs **regardless of the app-data outcome**. The two are +/// only coupled at the end, when the terminal state is published: a legacy vote +/// row that cannot be imported must never stand between the user and their +/// seeds. +/// +/// # Errors /// -/// Drains single-key wallet rows, HD wallet seeds, and wallet metadata -/// into the upstream store, registers the migrated wallets, then writes -/// the completion sentinel. +/// [`TaskError::MigrationFailed`] when the wallet drain fails (the completion +/// sentinel stays unwritten, so the next launch retries), or when the wallet +/// drain succeeded but the app-data pass hit a hard failure — an unreadable +/// legacy file, a k/v write error. Undecodable vote rows are *not* an error: +/// they are counted and reported on [`MigrationState::SucceededWithUnreadableVotes`], +/// because a retry cannot decode a corrupt row and failing here would wedge the +/// wallet drain on every launch. pub async fn run(app_context: &Arc) -> Result { let status = app_context.migration_status(); - let app_kv = app_context.app_kv(); - let network = app_context.network; // Scheduled votes and top-up history carry their own sentinel and run // ahead of the wallet-drain gate below: an install that already completed @@ -260,7 +263,63 @@ pub async fn run(app_context: &Arc) -> Result { status.set_state(MigrationState::Running { step: MigrationStep::AppData, }); - let app_data_moved = migrate_app_data(app_context)?; + let app_data = migrate_app_data(app_context); + + // The app-data result is deliberately held, not propagated: the wallet drain + // is what restores access to funds, so nothing about DET's own rows may gate + // it. Propagating here would let one bad vote row wedge the drain on every + // launch, with no user-reachable way out. + let wallet_moved = match drain_wallets(app_context).await { + Ok(moved) => moved, + Err(drain_error) => { + if let Err(app_data_error) = &app_data { + tracing::warn!( + target = "migration::finish_unwire", + error = ?app_data_error, + "App-data import failed on the same launch as the wallet drain; both retry on the next launch", + ); + } + return Err(drain_error); + } + }; + + // Funds are reachable from here on, so the app-data outcome can now decide + // the terminal state. A hard failure leaves the app-data sentinel unwritten + // and reaches the user's "Retry now" banner; that retry re-runs the import + // alone, since the wallet drain short-circuits on its own sentinel. + let app_data = app_data?; + let moved_data = wallet_moved || app_data.moved_data(); + + if app_data.votes_unreadable > 0 { + tracing::warn!( + target = "migration::finish_unwire", + unreadable = app_data.votes_unreadable, + imported = app_data.votes_imported, + network = ?app_context.network, + "Some legacy scheduled votes could not be decoded; they stay in the previous version's data.db and must be scheduled again", + ); + status.set_state(MigrationState::SucceededWithUnreadableVotes { + count: app_data.votes_unreadable, + }); + return Ok(moved_data); + } + + status.set_state(terminal_state(moved_data)); + Ok(moved_data) +} + +/// Drain the legacy wallet family — single-key rows, HD wallet seeds, wallet +/// metadata — into the upstream store, register the migrated wallets, then +/// record the per-network completion sentinel. +/// +/// Returns `true` when this launch drained wallet rows, `false` for the two +/// no-op paths (sentinel already present, or no legacy rows at all). This is +/// the funds path: [`run`] keeps it free of every DET-owned concern so nothing +/// but a genuine wallet-migration failure can withhold access to a seed. +async fn drain_wallets(app_context: &Arc) -> Result { + let status = app_context.migration_status(); + let app_kv = app_context.app_kv(); + let network = app_context.network; // Idempotency: if the sentinel for *this network* already exists, // this launch has nothing to do. The sentinel is per-network @@ -276,11 +335,7 @@ pub async fn run(app_context: &Arc) -> Result { network_count = completion.network_count, "FinishUnwire already completed for this network — skipping", ); - // The wallet drain was already done by a prior run. The app-data pass - // above may still have moved votes on this launch — report its outcome - // so the completion banner appears exactly when work happened. - status.set_state(terminal_state(app_data_moved)); - return Ok(app_data_moved); + return Ok(false); } status.set_state(MigrationState::Running { @@ -295,11 +350,7 @@ pub async fn run(app_context: &Arc) -> Result { "No legacy data.db rows detected — writing sentinel without migration", ); write_sentinel(&app_kv, network, 0)?; - // No wallet rows to move (e.g. a fresh install): record the sentinel. - // The banner still follows the app-data pass, which runs even when the - // wallet tables are empty. - status.set_state(terminal_state(app_data_moved)); - return Ok(app_data_moved); + return Ok(false); } tracing::info!( @@ -347,9 +398,8 @@ pub async fn run(app_context: &Arc) -> Result { tracing::info!( target = "migration::finish_unwire", network = ?network, - "FinishUnwire migration complete", + "FinishUnwire wallet drain complete", ); - status.set_state(MigrationState::Success); Ok(true) } @@ -411,16 +461,21 @@ pub fn app_data_sentinel_key_for(network: Network) -> String { /// Import the DET-owned rows the wallet drain never touched: scheduled DPNS /// votes (deadline-critical) and top-up history (audit trail). /// -/// Returns `true` when this pass moved data. Idempotent — votes already in -/// the k/v store are left alone, so a retry can never overwrite a vote the -/// user has since cast with its stale legacy `executed` flag. +/// Returns the pass counters, including `votes_unreadable` — rows that could +/// not be decoded. Those are *not* an error: a corrupt row decodes no better on +/// a retry, so failing here would only wedge the launch. [`run`] reports the +/// count to the user instead, and the legacy rows are never deleted. +/// +/// Idempotent — votes already in the k/v store are left alone, so a retry can +/// never overwrite a vote the user has since cast with its stale legacy +/// `executed` flag. /// /// # Errors /// -/// [`MigrationError::ScheduledVotesUnreadable`] when a legacy vote row cannot -/// be decoded. The sentinel stays unwritten and the error reaches the banner: -/// a scheduled vote that vanishes without a word is a missed vote window. -fn migrate_app_data(app_context: &Arc) -> Result { +/// [`TaskError::MigrationFailed`] when the legacy file cannot be opened or read, +/// or the decoded votes cannot be written to the k/v store. The app-data +/// sentinel stays unwritten in those cases, so the next launch retries. +fn migrate_app_data(app_context: &Arc) -> Result { let app_kv = app_context.app_kv(); let network = app_context.network; let sentinel_key = app_data_sentinel_key_for(network); @@ -430,15 +485,15 @@ fn migrate_app_data(app_context: &Arc) -> Result { .map_err(|source| MigrationError::Sentinel { source })? .is_some(); if done { - return Ok(false); + return Ok(AppDataMigrationOutcome::default()); } let Some(path) = app_context.db.db_file_path() else { // In-memory / headless: no legacy file to import from. - return Ok(false); + return Ok(AppDataMigrationOutcome::default()); }; if !path.exists() { - return Ok(false); + return Ok(AppDataMigrationOutcome::default()); } let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { path: path.to_string_lossy().to_string(), @@ -451,7 +506,7 @@ fn migrate_app_data(app_context: &Arc) -> Result { // legacy data would fail on a dependency it never actually needs. if !table_has_rows(&conn, "scheduled_votes")? && !table_has_rows(&conn, "top_up")? { write_app_data_sentinel(&app_kv, &sentinel_key)?; - return Ok(false); + return Ok(AppDataMigrationOutcome::default()); } // There is data to move, so the k/v store — and therefore the backend — @@ -481,14 +536,19 @@ fn migrate_app_data(app_context: &Arc) -> Result { target = "migration::finish_unwire", votes_imported = outcome.votes_imported, votes_skipped_existing = outcome.votes_skipped_existing, + votes_unreadable = outcome.votes_unreadable, top_up_identities_imported = outcome.top_up_identities_imported, network = ?network, "App-data migration pass complete", ); + // The sentinel is written even when rows were unreadable: every *importable* + // row is now in the k/v store, and the undecodable ones will never decode. A + // withheld sentinel would re-run this import on every launch, which would + // resurrect votes the user has since cast and cleared from the queue. write_app_data_sentinel(&app_kv, &sentinel_key)?; - Ok(outcome.votes_imported > 0 || outcome.top_up_identities_imported > 0) + Ok(outcome) } /// Record the app-data import as complete for one network. Reuses the @@ -583,15 +643,6 @@ where ), } - // Votes last: everything readable is safely in the k/v store before the - // pass reports failure, so a retry only has the corrupt rows left to face. - if outcome.votes_unreadable > 0 { - return Err(MigrationError::ScheduledVotesUnreadable { - imported: outcome.votes_imported, - unreadable: outcome.votes_unreadable, - }); - } - Ok(outcome) } @@ -659,14 +710,23 @@ struct AppDataMigrationOutcome { /// alone. A retry must not resurrect a stale `executed` flag over a vote /// the user has since cast. votes_skipped_existing: u32, - /// Legacy vote rows that could not be decoded. Fatal — the sentinel stays - /// unwritten so the user sees the failure banner rather than losing a vote - /// silently ahead of its deadline. + /// Legacy vote rows that could not be decoded (corrupt voter id or vote + /// choice). Non-fatal — a corrupt row decodes no better on a retry, and + /// failing the pass would wedge the wallet drain behind it. Surfaced to the + /// user by [`run`] instead, so a vote is never lost in silence. votes_unreadable: u32, /// Identities whose top-up history was written into the k/v store. top_up_identities_imported: u32, } +impl AppDataMigrationOutcome { + /// Whether this pass actually carried data across — the signal [`run`] uses + /// to decide whether the launch earns a completion banner. + fn moved_data(&self) -> bool { + self.votes_imported > 0 || self.top_up_identities_imported > 0 + } +} + /// Outcome counters from one [`migrate_single_key_rows`] pass. Public /// to the test module so partial-failure semantics can be asserted /// without invoking the AppContext-bound orchestrator. @@ -1627,35 +1687,6 @@ mod tests { DetKv::from_store(Arc::new(InMemoryKv::default())) } - /// TC-MIG-009 — calling the migration when the sentinel for the - /// active network is already present must be a no-op. The - /// orchestrator must not consult legacy `data.db`, must not move - /// state into `Running`, and must leave the sentinel untouched. - #[test] - fn sentinel_short_circuits_run() { - use dash_sdk::dpp::dashcore::Network; - - let kv = kv(); - let original = MigrationCompletion { - completed_at: 1234, - sha: "test-sha".into(), - network_count: 1, - }; - kv.put( - DetScope::Global, - &sentinel_key_for(Network::Testnet), - &original, - ) - .expect("seed sentinel"); - - // Reading the sentinel back via the same path the orchestrator - // uses is the contractual short-circuit hook. If this returns - // `Some`, the orchestrator skips legacy detection entirely. - let observed: Option = - read_sentinel(&kv, Network::Testnet).expect("read sentinel"); - assert_eq!(observed, Some(original)); - } - // ── App-data import: scheduled votes + top-up history ──────────── mod app_data { @@ -1779,10 +1810,13 @@ mod tests { ); } - /// An undecodable vote row is surfaced as a failure, never dropped in - /// silence — the user must learn their vote did not come across. + /// An undecodable vote row is counted and reported — never dropped in + /// silence, and never fatal. Fatal would be worse than useless: the row + /// decodes no better on a retry, so it would wedge every launch, and the + /// wallet drain behind it (QA-101). The readable votes around it still + /// import. #[test] - fn unreadable_vote_row_fails_the_import() { + fn unreadable_vote_row_is_counted_without_failing_the_import() { let conn = legacy_conn(); conn.execute( "INSERT INTO scheduled_votes @@ -1792,25 +1826,26 @@ mod tests { ) .expect("corrupt vote"); - let err = migrate_app_data_from_conn( + let votes = RefCell::new(Vec::new()); + let outcome = migrate_app_data_from_conn( &conn, Network::Testnet, &BTreeSet::new(), - |_| Ok(()), + |v| { + votes.borrow_mut().extend_from_slice(v); + Ok(()) + }, |_, _| Ok(()), ) - .expect_err("an unreadable vote must fail the import"); + .expect("an unreadable vote must not fail the import"); - assert!( - matches!( - err, - MigrationError::ScheduledVotesUnreadable { - imported: 1, - unreadable: 1 - } - ), - "unexpected error: {err:?}", + assert_eq!(outcome.votes_imported, 1, "the readable vote still lands"); + assert_eq!( + outcome.votes_unreadable, 1, + "the corrupt row must be reported, not swallowed", ); + assert_eq!(votes.borrow().len(), 1); + assert_eq!(votes.borrow()[0].contested_name, "alice"); } /// A fresh install has none of these tables — a no-op, not an error. @@ -3411,6 +3446,198 @@ mod tests { ); } + /// Wire the real wallet seam onto a fixture context. Offline: the backend + /// builds its sidecars and hydrates from them without touching the network, + /// so an end-to-end `run()` can be driven to completion in a unit test. The + /// wallet drain aborts at its first step without a wired backend, so the + /// funds-reachability tests below cannot use [`fresh_app_context`] alone. + async fn wire_backend(app_context: &Arc) { + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, app_context.egui_ctx().clone()); + app_context + .ensure_wallet_backend(sender) + .await + .expect("wallet backend must wire offline"); + } + + /// Stage the v0.10-dev vote queue: the legacy table plus the rows given as + /// `(contested_name, vote_choice)`. A `vote_choice` the reader cannot parse + /// is the corrupt row an upgrade has to survive. + fn seed_legacy_votes( + app_context: &Arc, + voter: &[u8; 32], + rows: &[(&str, &str)], + network: dash_sdk::dpp::dashcore::Network, + ) { + crate::database::test_helpers::create_legacy_scheduled_votes_table(&app_context.db) + .expect("create legacy scheduled_votes table"); + for (contested_name, vote_choice) in rows { + crate::database::test_helpers::seed_legacy_scheduled_vote_row( + &app_context.db, + voter, + contested_name, + vote_choice, + network, + ) + .expect("insert legacy scheduled vote row"); + } + } + + /// Stage a legacy unprotected HD wallet row whose xpub derives from its seed, + /// so the drain produces a wallet the registration gate accepts. Returns the + /// seed hash the wallet is keyed by. + fn seed_legacy_wallet( + app_context: &Arc, + seed: &[u8; 64], + alias: &str, + network: dash_sdk::dpp::dashcore::Network, + ) -> crate::model::wallet::WalletSeedHash { + let seed_hash = crate::model::wallet::ClosedKeyItem::compute_seed_hash(seed); + let epk = crate::database::test_helpers::legacy_master_epk_bytes(seed, network); + crate::database::test_helpers::seed_legacy_unprotected_hd_wallet_row( + &app_context.db, + &seed_hash, + seed, + &epk, + alias, + network, + ) + .expect("insert legacy wallet row"); + seed_hash + } + + /// QA-101 — the headline funds regression. A single undecodable legacy vote + /// row must NOT stand between the user and their wallet: the drain runs to + /// completion (seeds copied, wallet hydrated AND upstream-registered, the + /// completion sentinel written), while the corrupt row is still surfaced — + /// counted on the terminal state and left in `data.db` — rather than + /// silently dropped. Before the fix the vote import ran first, unconditionally + /// and fatally, so this wallet stayed unreachable on every launch forever. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn run_completes_the_wallet_drain_despite_an_unreadable_vote_row() { + use crate::wallet_backend::poison::RwLockRecover; + use dash_sdk::dpp::dashcore::Network; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + let network = Network::Testnet; + + let seed_hash = seed_legacy_wallet(&ctx, &[0xA3u8; 64], "funds", network); + let voter = [0x11u8; 32]; + seed_legacy_votes( + &ctx, + &voter, + &[("alice", "Lock"), ("corrupt", "Nonsense")], + network, + ); + + wire_backend(&ctx).await; + let backend = ctx.wallet_backend().expect("backend wired"); + assert!( + !backend.is_wallet_registered(&seed_hash), + "precondition: the legacy wallet is not migrated yet", + ); + + run(&ctx) + .await + .expect("an unreadable vote row must not fail the wallet migration"); + + // Funds first: hydrated into `ctx.wallets`, registered in the same + // `id_map` that `resolve_wallet` consults, and the drain recorded as done. + assert!( + ctx.wallets.read_recover().contains_key(&seed_hash), + "the migrated wallet must be visible after the migration", + ); + assert!( + backend.is_wallet_registered(&seed_hash), + "the migrated wallet must be reachable — a corrupt vote row must never \ + block access to funds", + ); + assert!( + read_sentinel(&ctx.app_kv(), network) + .expect("read sentinel") + .is_some(), + "the completion sentinel must be written once the drain succeeds", + ); + + // No silent loss: the readable vote came across, and the unreadable one is + // reported on the terminal state (the banner the user sees), not swallowed. + let votes = ctx.get_scheduled_votes().expect("read scheduled votes"); + assert_eq!(votes.len(), 1, "the readable vote must still be imported"); + assert_eq!(votes[0].contested_name, "alice"); + assert_eq!( + *ctx.migration_status().state(), + MigrationState::SucceededWithUnreadableVotes { count: 1 }, + "the corrupt vote row must be surfaced to the user, not dropped in silence", + ); + + // The legacy rows survive, so a build with a better decoder can still get + // the vote back. + let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); + let remaining: i64 = conn + .query_row("SELECT COUNT(*) FROM scheduled_votes", [], |r| r.get(0)) + .expect("count legacy votes"); + assert_eq!(remaining, 2, "the migration must never delete legacy rows"); + + backend.shutdown().await; + } + + /// TC-MIG-009 — end-to-end idempotency: `run()` called TWICE on the same + /// `AppContext` must make the second launch a true no-op. Not just "the + /// sentinel reads back" — the second pass must re-fire no side effect, which + /// this proves by clearing the vote queue between the runs (what the app does + /// once a vote has been cast) and requiring the re-run NOT to resurrect it + /// from the legacy row that is still sitting in `data.db`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn second_run_on_the_same_context_re_fires_nothing() { + use dash_sdk::dpp::dashcore::Network; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + let network = Network::Testnet; + + let seed_hash = seed_legacy_wallet(&ctx, &[0xB4u8; 64], "funds", network); + seed_legacy_votes(&ctx, &[0x22u8; 32], &[("alice", "Lock")], network); + + wire_backend(&ctx).await; + let backend = ctx.wallet_backend().expect("backend wired"); + + assert!( + run(&ctx).await.expect("first run"), + "the first launch moves legacy data", + ); + assert!(backend.is_wallet_registered(&seed_hash)); + assert_eq!(ctx.get_scheduled_votes().expect("read votes").len(), 1); + let sentinel_after_first = read_sentinel(&ctx.app_kv(), network) + .expect("read sentinel") + .expect("sentinel written by the first run"); + + // The user casts the vote, so the app drops it from the queue. The legacy + // row still says `executed = 0` — a re-import would queue it a second time. + ctx.clear_all_scheduled_votes().expect("clear vote queue"); + + let did_work = run(&ctx).await.expect("second run"); + + assert!(!did_work, "the second launch must move nothing"); + assert!( + ctx.get_scheduled_votes().expect("read votes").is_empty(), + "a re-run must not resurrect a vote the user has already dealt with", + ); + assert_eq!( + read_sentinel(&ctx.app_kv(), network) + .expect("read sentinel") + .expect("sentinel still present"), + sentinel_after_first, + "a no-op launch must not rewrite the completion sentinel", + ); + assert!( + matches!(*ctx.migration_status().state(), MigrationState::Idle), + "a no-op launch must not publish a completion banner", + ); + + backend.shutdown().await; + } + /// Funds-safety invariant (Fix #3): a run that cannot finish — here because /// no wallet backend is wired in the fixture — MUST return `Err` and MUST /// NOT write the completion sentinel, so the migration retries on a later diff --git a/src/context/migration_status.rs b/src/context/migration_status.rs index f064d0c51..e09186233 100644 --- a/src/context/migration_status.rs +++ b/src/context/migration_status.rs @@ -59,6 +59,12 @@ pub enum MigrationState { Running { step: MigrationStep }, /// Migration completed successfully (or no legacy data was present). Success, + /// The wallet drain completed — seeds, metadata and registration all + /// landed, so funds are reachable — but `count` legacy scheduled votes + /// could not be decoded and did not come across. Terminal and non-fatal: + /// the legacy rows are never deleted, and a retry cannot decode a corrupt + /// row, so the user is told once rather than offered a futile retry. + SucceededWithUnreadableVotes { count: u32 }, /// Migration failed. The wrapped error is rendered for the user via /// its `Display` impl at banner-render time; the typed chain is /// preserved for the details panel and logs. @@ -79,6 +85,10 @@ impl PartialEq for MigrationState { match (self, other) { (MigrationState::Idle, MigrationState::Idle) => true, (MigrationState::Success, MigrationState::Success) => true, + ( + MigrationState::SucceededWithUnreadableVotes { count: a }, + MigrationState::SucceededWithUnreadableVotes { count: b }, + ) => a == b, (MigrationState::Running { step: a }, MigrationState::Running { step: b }) => a == b, (MigrationState::Failed { error: a }, MigrationState::Failed { error: b }) => { Arc::ptr_eq(a, b) diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 5da59f967..8c0b7edbb 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1594,21 +1594,9 @@ async fn register_wallet_fails_closed_when_wallet_meta_write_fails() { ); } -/// Build a valid BIP44 account-0 master xpub for a legacy wallet row. +/// Build a valid BIP44 account-0 master xpub (testnet) for a legacy wallet row. fn legacy_master_epk_bytes(seed: &[u8; 64]) -> Vec { - use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; - use dash_sdk::dpp::key_wallet::bip32::{ - ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, - }; - let secp = Secp256k1::new(); - let master = ExtendedPrivKey::new_master(Network::Testnet, seed).expect("master key"); - let path = DerivationPath::from(vec![ - ChildNumber::Hardened { index: 44 }, - ChildNumber::Hardened { index: 1 }, - ChildNumber::Hardened { index: 0 }, - ]); - let account = master.derive_priv(&secp, &path).expect("derive account"); - ExtendedPubKey::from_priv(&secp, &account).encode().to_vec() + crate::database::test_helpers::legacy_master_epk_bytes(seed, Network::Testnet) } /// F140 — a wallet migrated from legacy `data.db` must be visible right diff --git a/src/database/test_helpers.rs b/src/database/test_helpers.rs index ef76f1af1..af2cd115c 100644 --- a/src/database/test_helpers.rs +++ b/src/database/test_helpers.rs @@ -149,6 +149,77 @@ pub fn seed_legacy_protected_hd_wallet_row( Ok(()) } +/// BIP44 ECDSA account-0 extended-public-key bytes for `seed`, the value a +/// legacy `wallet` row carries in `master_ecdsa_bip44_account_0_epk`. The +/// migration copies it verbatim and the W2 fund-routing gate matches on it, so a +/// staged legacy row needs an xpub that genuinely derives from its seed. +pub fn legacy_master_epk_bytes( + seed: &[u8; 64], + network: dash_sdk::dpp::dashcore::Network, +) -> Vec { + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::dashcore::secp256k1::Secp256k1; + use dash_sdk::dpp::key_wallet::bip32::{ + ChildNumber, DerivationPath, ExtendedPrivKey, ExtendedPubKey, + }; + + let coin_type = if network == Network::Mainnet { 5 } else { 1 }; + let secp = Secp256k1::new(); + let master = ExtendedPrivKey::new_master(network, seed).expect("master key"); + let path = DerivationPath::from(vec![ + ChildNumber::Hardened { index: 44 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 0 }, + ]); + let account = master.derive_priv(&secp, &path).expect("derive account"); + ExtendedPubKey::from_priv(&secp, &account).encode().to_vec() +} + +/// Create the legacy `scheduled_votes` table in `data.db`. Fresh installs no +/// longer create it (the unwire dropped it from `create_tables`), so a test that +/// stages a v0.10-dev vote queue has to put it back exactly as the old schema +/// had it. +pub fn create_legacy_scheduled_votes_table(db: &Database) -> rusqlite::Result<()> { + db.execute( + "CREATE TABLE IF NOT EXISTS scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0, + network TEXT NOT NULL, + PRIMARY KEY (identity_id, contested_name) + )", + rusqlite::params![], + )?; + Ok(()) +} + +/// Insert one row into the legacy `scheduled_votes` table. `vote_choice` is the +/// `Display` form of `ResourceVoteChoice` (`Abstain`, `Lock`, +/// `TowardsIdentity()`); pass an unparseable string to stage the corrupt +/// row a migration must survive. +pub fn seed_legacy_scheduled_vote_row( + db: &Database, + voter_id: &[u8; 32], + contested_name: &str, + vote_choice: &str, + network: dash_sdk::dpp::dashcore::Network, +) -> rusqlite::Result<()> { + db.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, ?2, ?3, 1700000000, 0, ?4)", + rusqlite::params![ + voter_id.as_slice(), + contested_name, + vote_choice, + network.to_string(), + ], + )?; + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index 1eb41ac1b..222a0660d 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -75,7 +75,11 @@ pub fn derive_shielded_indicator(state: &MigrationState, skipped: bool) -> Shiel step: MigrationStep::Shielded, } => ShieldedIndicator::Verifying, MigrationState::Failed { .. } => ShieldedIndicator::Failed, - MigrationState::Success => ShieldedIndicator::Verified, + // Unreadable scheduled votes say nothing about shielded data: the wallet + // drain completed, so the balance is as authoritative as on `Success`. + MigrationState::Success | MigrationState::SucceededWithUnreadableVotes { .. } => { + ShieldedIndicator::Verified + } // Idle / non-shielded running step → no badge. MigrationState::Idle | MigrationState::Running { .. } => ShieldedIndicator::Hidden, } @@ -866,6 +870,14 @@ mod tests { derive_shielded_indicator(&MigrationState::Success, false), ShieldedIndicator::Verified, ); + assert_eq!( + derive_shielded_indicator( + &MigrationState::SucceededWithUnreadableVotes { count: 1 }, + false, + ), + ShieldedIndicator::Verified, + "an unreadable vote row says nothing about shielded data — the drain completed", + ); // Skip-for-now hides the indicator regardless of state — the // session-local override the UI uses to dismiss the retry // banner. diff --git a/tests/kittest/migration_banner.rs b/tests/kittest/migration_banner.rs index 3e34c2521..939fc10f5 100644 --- a/tests/kittest/migration_banner.rs +++ b/tests/kittest/migration_banner.rs @@ -5,7 +5,9 @@ //! step-label table or the action-button plumbing fails here without //! needing a full `AppState` harness. -use dash_evo_tool::app::{MIGRATION_RETRY_ACTION_ID, migration_running_text}; +use dash_evo_tool::app::{ + MIGRATION_RETRY_ACTION_ID, migration_running_text, migration_unreadable_votes_text, +}; use dash_evo_tool::context::migration_status::MigrationStep; use dash_evo_tool::ui::MessageType; use dash_evo_tool::ui::components::MessageBanner; @@ -151,3 +153,34 @@ fn tc_a11y_004_failure_banner_uses_icon_and_text() { .is_some(), ); } + +/// QA-101 — a migration that drained the wallets but could not read some legacy +/// scheduled votes surfaces a Warning banner naming the recovery action, and +/// offers NO "Retry now": the drain is done and a corrupt row decodes no better +/// on a second pass, so a retry button would be a dead end. +#[test] +fn unreadable_votes_banner_warns_without_a_retry_action() { + let text = migration_unreadable_votes_text(2); + assert!( + text.ends_with('.'), + "banner copy must be a complete sentence for i18n extraction: `{text}`", + ); + + let label = text.clone(); + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 200.0)) + .build_ui(move |ui| { + MessageBanner::set_global(ui.ctx(), label.clone(), MessageType::Warning); + MessageBanner::show_global(ui); + }); + harness.run(); + + assert!( + harness.query_by_label(text.as_str()).is_some(), + "the warning banner must render the unreadable-votes copy verbatim", + ); + assert!( + harness.query_by_label("Retry now").is_none(), + "a completed drain must not offer a retry the user cannot benefit from", + ); +} From 108af44104b754193451b145f981e5bfb778b20a Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:14:34 +0000 Subject: [PATCH 16/30] refactor: consolidate six duplicated code paths from the DRY audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/dashpay.rs | 17 ++- .../dashpay/auto_accept_handler.rs | 11 +- src/backend_task/dashpay/contact_requests.rs | 130 +++++------------- src/backend_task/dashpay/contacts.rs | 46 ++----- src/backend_task/migration/finish_unwire.rs | 8 +- .../migration/single_key_restore.rs | 12 +- src/database/initialization.rs | 6 +- src/database/legacy_import.rs | 18 +-- src/database/mod.rs | 30 ++++ src/database/settings.rs | 90 ++---------- src/database/wallet.rs | 20 +-- src/model/dashpay.rs | 62 +++++++++ src/ui/components/global_nav_switcher.rs | 4 +- src/ui/dashpay/contact_requests.rs | 6 +- src/ui/identity/contacts.rs | 54 ++++---- src/ui/identity/identity_pill.rs | 76 +++++++--- src/ui/state/contacts_view.rs | 80 ++++++----- 17 files changed, 314 insertions(+), 356 deletions(-) diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index f1dfe8725..debb92c18 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -22,7 +22,22 @@ pub use contacts::ContactData; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; -use dash_sdk::platform::{Identifier, IdentityPublicKey}; +use dash_sdk::platform::{DocumentQuery, Identifier, IdentityPublicKey}; +use errors::DashPayError; + +/// A fresh `contactRequest` [`DocumentQuery`] against the cached DashPay +/// contract. Callers add their own `where` / `order by` clauses and limit. +/// +/// Every `contactRequest` read in this module goes through here, so the +/// contract handle and the error attribution are stated once. +fn contact_request_query(app_context: &AppContext) -> Result { + DocumentQuery::new(app_context.dashpay_contract.clone(), "contactRequest").map_err(|e| { + DashPayError::QueryCreation { + query_target: "DashPay contactRequest", + source: Box::new(e), + } + }) +} #[derive(Debug, Clone, PartialEq)] pub enum DashPayTask { diff --git a/src/backend_task/dashpay/auto_accept_handler.rs b/src/backend_task/dashpay/auto_accept_handler.rs index 5ec941065..77135594c 100644 --- a/src/backend_task/dashpay/auto_accept_handler.rs +++ b/src/backend_task/dashpay/auto_accept_handler.rs @@ -1,6 +1,6 @@ use crate::backend_task::dashpay::auto_accept_proof::verify_auto_accept_proof; +use crate::backend_task::dashpay::contact_request_query; use crate::backend_task::dashpay::contact_requests::accept_contact_request; -use crate::backend_task::dashpay::errors::DashPayError; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; @@ -10,7 +10,7 @@ use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::Value; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::drive::query::{OrderClause, WhereClause, WhereOperator}; -use dash_sdk::platform::{Document, DocumentQuery, FetchMany, Identifier}; +use dash_sdk::platform::{Document, FetchMany, Identifier}; use std::sync::Arc; /// Process incoming contact requests and check for autoAcceptProof @@ -23,14 +23,9 @@ pub async fn process_auto_accept_requests( identity: QualifiedIdentity, ) -> Result, TaskError> { let identity_id = identity.identity.id(); - let dashpay_contract = app_context.dashpay_contract.clone(); // Query for incoming contact requests - let mut incoming_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; + let mut incoming_query = contact_request_query(app_context)?; incoming_query = incoming_query.with_where(WhereClause { field: "toUserId".to_string(), diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index b9f0caa02..1c6889903 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -1,3 +1,4 @@ +use super::contact_request_query; use super::encryption::{ encrypt_account_label, encrypt_extended_public_key, generate_ecdh_shared_key, }; @@ -9,6 +10,7 @@ use crate::backend_task::dashpay::auto_accept_proof::{ }; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::contact_request_recipient; use crate::model::qualified_identity::QualifiedIdentity; // Upstream contact-request type: used to record the sent request in the // local wallet-manager so dashpay_sync can auto-establish the contact. @@ -38,7 +40,6 @@ pub async fn load_contact_requests( identity: QualifiedIdentity, ) -> Result { let identity_id = identity.identity.id(); - let dashpay_contract = app_context.dashpay_contract.clone(); tracing::info!( "Loading contact requests for identity: {}", @@ -46,18 +47,12 @@ pub async fn load_contact_requests( ); // Query for incoming contact requests (where toUserId == our identity) - let mut incoming_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; - - let query_value = Value::Identifier(identity_id.to_buffer()); + let mut incoming_query = contact_request_query(app_context)?; incoming_query = incoming_query.with_where(WhereClause { field: "toUserId".to_string(), operator: WhereOperator::Equal, - value: query_value.clone(), + value: Value::Identifier(identity_id.to_buffer()), }); // Without this orderBy, the query returns 0 results even when documents exist @@ -68,13 +63,7 @@ pub async fn load_contact_requests( incoming_query.limit = 50; // Query for outgoing contact requests (where $ownerId == our identity) - let mut outgoing_query = - DocumentQuery::new(dashpay_contract, "contactRequest").map_err(|e| { - DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - } - })?; + let mut outgoing_query = contact_request_query(app_context)?; outgoing_query = outgoing_query.with_where(WhereClause { field: "$ownerId".to_string(), @@ -111,43 +100,24 @@ pub async fn load_contact_requests( // Filter out mutual requests (where both parties have sent requests to each other) // These are now contacts, not pending requests - let mut contacts_established = HashSet::new(); - - // Check each incoming request - for (_, incoming_doc) in incoming.iter() { - let from_id = incoming_doc.owner_id(); - - // Check if we also sent a request to this person - for (_, outgoing_doc) in outgoing.iter() { - if let Some(Value::Identifier(to_id_bytes)) = outgoing_doc.properties().get("toUserId") - { - // Parse the identifier, skip if invalid - let Ok(to_id) = Identifier::from_bytes(to_id_bytes.as_slice()) else { - tracing::warn!("Invalid toUserId in contact request document, skipping"); - continue; - }; - if to_id == from_id { - // Mutual request found - they are now contacts - contacts_established.insert(from_id); - } - } - } - } + let contacts_established: HashSet = incoming + .iter() + .map(|(_, doc)| doc.owner_id()) + .filter(|from_id| { + outgoing + .iter() + .any(|(_, doc)| contact_request_recipient(doc).as_ref() == Some(from_id)) + }) + .collect(); - // Filter out established contacts from both lists + // Filter out established contacts from both lists. An outgoing document + // with an unreadable recipient is kept: it cannot be attributed, and hiding + // a request the user may still be waiting on is the worse failure. incoming.retain(|(_, doc)| !contacts_established.contains(&doc.owner_id())); - outgoing.retain(|(_, doc)| { - if let Some(Value::Identifier(to_id_bytes)) = doc.properties().get("toUserId") { - // Parse the identifier, keep the document if we can't parse (defensive) - let Ok(to_id) = Identifier::from_bytes(to_id_bytes.as_slice()) else { - tracing::warn!("Invalid toUserId in outgoing contact request, keeping in list"); - return true; - }; - !contacts_established.contains(&to_id) - } else { - true - } + outgoing.retain(|(_, doc)| match contact_request_recipient(doc) { + Some(to_id) => !contacts_established.contains(&to_id), + None => true, }); // Drop requests the user has already resolved locally (declined an incoming @@ -172,13 +142,6 @@ pub async fn load_contact_requests( Ok(BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing }) } -/// The recipient (`toUserId`) of a contact-request document. -fn recipient_of(doc: &Document) -> Option { - doc.properties() - .get("toUserId") - .and_then(|v| v.to_identifier().ok()) -} - /// Drop every request whose counterparty the user has already resolved, per /// `is_resolved`. An outgoing document with an unreadable `toUserId` is kept: /// we cannot prove it was resolved, and hiding a request the user may still be @@ -189,7 +152,7 @@ fn retain_unresolved( is_resolved: impl Fn(&Identifier) -> bool, ) { incoming.retain(|(_, doc)| !is_resolved(&doc.owner_id())); - outgoing.retain(|(_, doc)| match recipient_of(doc) { + outgoing.retain(|(_, doc)| match contact_request_recipient(doc) { Some(to) => !is_resolved(&to), None => true, }); @@ -205,7 +168,7 @@ fn recipient_of_sent_request( if doc.owner_id() != *owner { return Err(DashPayError::ContactRequestNotSentByYou); } - recipient_of(doc).ok_or_else(|| DashPayError::InvalidDocument { + contact_request_recipient(doc).ok_or_else(|| DashPayError::InvalidDocument { reason: "contact request document is missing its toUserId field".to_string(), }) } @@ -279,11 +242,7 @@ pub async fn send_contact_request_with_proof( // Step 3: Check if a contact request already exists let dashpay_contract = app_context.dashpay_contract.clone(); - let mut existing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; + let mut existing_query = contact_request_query(app_context)?; existing_query = existing_query .with_where(WhereClause { @@ -699,16 +658,9 @@ pub async fn accept_contact_request( // According to DashPay DIP, accepting means sending a contact request back // First, we need to fetch the incoming contact request to get the sender's identity - let dashpay_contract = app_context.dashpay_contract.clone(); - // Fetch the specific contact request document by creating a query with its ID - let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest").map_err(|e| { - DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - } - })?; - let query_with_id = DocumentQuery::with_document_id(query, &request_id); + let query_with_id = + DocumentQuery::with_document_id(contact_request_query(app_context)?, &request_id); let doc = Document::fetch(sdk, query_with_id) .await? @@ -718,11 +670,7 @@ pub async fn accept_contact_request( let from_identity_id = doc.owner_id(); // Check if we already sent a contact request to this identity - let mut existing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; + let mut existing_query = contact_request_query(app_context)?; existing_query = existing_query .with_where(WhereClause { @@ -843,15 +791,8 @@ pub async fn reject_contact_request( // Instead, we should update our contactInfo document to mark this contact as hidden // First, fetch the contact request to get the sender's identity - let dashpay_contract = app_context.dashpay_contract.clone(); - - let query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest").map_err(|e| { - DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - } - })?; - let query_with_id = DocumentQuery::with_document_id(query, &request_id); + let query_with_id = + DocumentQuery::with_document_id(contact_request_query(app_context)?, &request_id); let doc = Document::fetch(sdk, query_with_id) .await? @@ -976,12 +917,7 @@ struct PlatformCancelOps<'a> { impl CancelOps for PlatformCancelOps<'_> { async fn reciprocal_request_exists(&self) -> Result { - let mut query = - DocumentQuery::new(self.app_context.dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; + let mut query = contact_request_query(self.app_context)?; query = query .with_where(WhereClause { field: "$ownerId".to_string(), @@ -1049,12 +985,8 @@ pub async fn cancel_contact_request( // Re-fetch the request rather than trusting the row the user clicked — the // list may be stale by seconds or by an identity switch. - let query = DocumentQuery::new(app_context.dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; - let query_with_id = DocumentQuery::with_document_id(query, &request_id); + let query_with_id = + DocumentQuery::with_document_id(contact_request_query(app_context)?, &request_id); let doc = Document::fetch(sdk, query_with_id) .await? diff --git a/src/backend_task/dashpay/contacts.rs b/src/backend_task/dashpay/contacts.rs index 08750a2de..f6e8e8f71 100644 --- a/src/backend_task/dashpay/contacts.rs +++ b/src/backend_task/dashpay/contacts.rs @@ -1,7 +1,9 @@ use crate::backend_task::BackendTaskSuccessResult; +use crate::backend_task::dashpay::contact_request_query; use crate::backend_task::dashpay::errors::DashPayError; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::contact_request_recipient; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::Sdk; use dash_sdk::dpp::data_contract::DataContract; @@ -154,11 +156,7 @@ pub async fn load_contacts( let dashpay_contract = app_context.dashpay_contract.clone(); // Query for contact requests where we are the sender (ownerId) - let mut outgoing_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; + let mut outgoing_query = contact_request_query(app_context)?; outgoing_query = outgoing_query.with_where(WhereClause { field: "$ownerId".to_string(), @@ -168,11 +166,7 @@ pub async fn load_contacts( outgoing_query.limit = 100; // Query for contact requests where we are the recipient (toUserId) - let mut incoming_query = DocumentQuery::new(dashpay_contract.clone(), "contactRequest") - .map_err(|e| DashPayError::QueryCreation { - query_target: "DashPay contactRequest", - source: Box::new(e), - })?; + let mut incoming_query = contact_request_query(app_context)?; incoming_query = incoming_query.with_where(WhereClause { field: "toUserId".to_string(), @@ -204,29 +198,15 @@ pub async fn load_contacts( .collect(); // Find mutual contacts (where both parties have sent requests to each other) - let mut contacts = HashSet::new(); - - for (_, incoming_doc) in incoming.iter() { - let from_id = incoming_doc.owner_id(); - - // Check if we also sent a request to this person - for (_, outgoing_doc) in outgoing.iter() { - if let Some(Value::Identifier(to_id_bytes)) = outgoing_doc.properties().get("toUserId") - { - let Ok(to_id) = Identifier::from_bytes(to_id_bytes.as_slice()) else { - tracing::warn!( - "Failed to parse contact request toUserId ({} bytes), skipping", - to_id_bytes.len() - ); - continue; - }; - if to_id == from_id { - // Mutual contact found - contacts.insert(from_id); - } - } - } - } + let contacts: HashSet = incoming + .iter() + .map(|(_, doc)| doc.owner_id()) + .filter(|from_id| { + outgoing + .iter() + .any(|(_, doc)| contact_request_recipient(doc).as_ref() == Some(from_id)) + }) + .collect(); // Now query for contact info documents let mut contact_info_query = DocumentQuery::new(dashpay_contract.clone(), "contactInfo") diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index d9d6b06d2..13252972b 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -1114,12 +1114,8 @@ fn legacy_table_exists_named( conn: &Connection, table: &'static str, ) -> Result { - conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - rusqlite::params![table], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .map_err(|e| MigrationError::LegacyDbRead { table, source: e }) + crate::database::table_exists(conn, table) + .map_err(|e| MigrationError::LegacyDbRead { table, source: e }) } /// Outcome counters from one [`migrate_wallet_meta_rows`] pass. diff --git a/src/backend_task/migration/single_key_restore.rs b/src/backend_task/migration/single_key_restore.rs index 3115c28ab..194e809c0 100644 --- a/src/backend_task/migration/single_key_restore.rs +++ b/src/backend_task/migration/single_key_restore.rs @@ -320,15 +320,11 @@ fn derive_p2pkh_address(wif: &str, network: Network) -> Result Result { - conn.query_row( - "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1", - rusqlite::params![name], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) - .map_err(|e| MigrationError::LegacyDbRead { + crate::database::table_exists(conn, name).map_err(|e| MigrationError::LegacyDbRead { table: "single_key_wallet", source: e, }) diff --git a/src/database/initialization.rs b/src/database/initialization.rs index 7d1b6ee8d..f04ffc99c 100644 --- a/src/database/initialization.rs +++ b/src/database/initialization.rs @@ -1663,11 +1663,7 @@ impl Database { /// Check if a table exists in the database. pub(crate) fn table_exists(&self, conn: &Connection, table: &str) -> rusqlite::Result { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - [table], - |row| row.get(0), - ) + crate::database::table_exists(conn, table) } /// Migration 29: rename network value `"dash"` to `"mainnet"` in all tables. diff --git a/src/database/legacy_import.rs b/src/database/legacy_import.rs index f408cf2fc..665ee13b5 100644 --- a/src/database/legacy_import.rs +++ b/src/database/legacy_import.rs @@ -20,7 +20,7 @@ use dash_sdk::platform::Identifier; use rusqlite::Connection; use crate::backend_task::contested_names::ScheduledDPNSVote; -use crate::database::Database; +use crate::database::{Database, column_exists, table_exists}; use crate::model::settings::{ AppSettings, RootScreenType, UserMode, network_from_legacy_str, theme_mode_from_str, }; @@ -292,22 +292,6 @@ fn mainnet_alias_for(network: Network) -> String { } } -fn table_exists(conn: &Connection, table: &str) -> rusqlite::Result { - conn.query_row( - "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", - [table], - |row| row.get(0), - ) -} - -fn column_exists(conn: &Connection, table: &str, column: &str) -> rusqlite::Result { - conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2", - rusqlite::params![table, column], - |row| row.get::<_, i64>(0).map(|c| c > 0), - ) -} - fn settings_columns(conn: &Connection) -> rusqlite::Result> { let mut stmt = conn.prepare("SELECT name FROM pragma_table_info('settings')")?; let names = stmt.query_map([], |row| row.get::<_, String>(0))?; diff --git a/src/database/mod.rs b/src/database/mod.rs index a6b164f32..8d8fdfe66 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -31,6 +31,36 @@ impl From for rusqlite::Error { } } +/// Whether `table` exists in the SQLite schema at `conn`. +/// +/// The one schema-existence probe: legacy `data.db` readers, the migration +/// ladder, and the migration tasks all run against tables that a fresh install +/// never creates, so "missing" is a normal answer, not an error. Callers that +/// need a domain-typed error map the `rusqlite::Error` themselves. +pub(crate) fn table_exists(conn: &Connection, table: &str) -> rusqlite::Result { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)", + [table], + |row| row.get(0), + ) +} + +/// Whether `table` has a column named `column`. +/// +/// A missing table has no columns, so it yields `false` rather than an error — +/// the migration ladder relies on that to stay idempotent. +pub(crate) fn column_exists( + conn: &Connection, + table: &str, + column: &str, +) -> rusqlite::Result { + conn.query_row( + "SELECT COUNT(*) FROM pragma_table_info(?1) WHERE name = ?2", + rusqlite::params![table, column], + |row| row.get::<_, i64>(0).map(|count| count > 0), + ) +} + #[derive(Debug)] pub struct Database { conn: Arc>, diff --git a/src/database/settings.rs b/src/database/settings.rs index 180999f6f..ac6142dbf 100644 --- a/src/database/settings.rs +++ b/src/database/settings.rs @@ -18,7 +18,7 @@ //! //! [`AppSettings::KV_KEY`]: crate::model::settings::AppSettings::KV_KEY -use crate::database::Database; +use crate::database::{Database, column_exists}; use rusqlite::{Connection, Result, params}; impl Database { @@ -26,26 +26,14 @@ impl Database { /// existing `settings` table. Kept only for the v3 migration arm — /// fresh installs never create these columns. pub fn add_custom_dash_qt_columns(&self, conn: &rusqlite::Connection) -> Result<()> { - let custom_dash_qt_path_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='custom_dash_qt_path'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !custom_dash_qt_path_exists { + if !column_exists(conn, "settings", "custom_dash_qt_path")? { conn.execute( "ALTER TABLE settings ADD COLUMN custom_dash_qt_path TEXT DEFAULT NULL;", (), )?; } - let overwrite_dash_conf_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='overwrite_dash_conf'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !overwrite_dash_conf_exists { + if !column_exists(conn, "settings", "overwrite_dash_conf")? { conn.execute( "ALTER TABLE settings ADD COLUMN overwrite_dash_conf INTEGER DEFAULT NULL;", (), @@ -58,13 +46,7 @@ impl Database { /// Backfill `theme_preference` on an existing `settings` table. /// Kept only for the v10 migration arm. pub fn add_theme_preference_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let theme_preference_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='theme_preference'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !theme_preference_exists { + if !column_exists(conn, "settings", "theme_preference")? { conn.execute( "ALTER TABLE settings ADD COLUMN theme_preference TEXT DEFAULT 'System';", (), @@ -77,13 +59,7 @@ impl Database { /// Backfill `disable_zmq` on an existing `settings` table. /// Kept only for the v12 migration arm. pub fn add_disable_zmq_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let disable_zmq_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='disable_zmq'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !disable_zmq_exists { + if !column_exists(conn, "settings", "disable_zmq")? { conn.execute( "ALTER TABLE settings ADD COLUMN disable_zmq INTEGER DEFAULT 0;", (), @@ -97,13 +73,7 @@ impl Database { /// Kept only for the v15 migration arm — the column is later dropped by /// [`Self::drop_core_backend_mode_column`] in the v38 arm. pub fn add_core_backend_mode_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='core_backend_mode'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !column_exists { + if !column_exists(conn, "settings", "core_backend_mode")? { conn.execute( "ALTER TABLE settings ADD COLUMN core_backend_mode INTEGER DEFAULT 1;", (), @@ -119,13 +89,7 @@ impl Database { /// idempotent — safe to re-run and a no-op on DBs that never had it. /// Used by the v38 migration arm. pub fn drop_core_backend_mode_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='core_backend_mode'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if column_exists { + if column_exists(conn, "settings", "core_backend_mode")? { conn.execute("ALTER TABLE settings DROP COLUMN core_backend_mode;", ())?; } @@ -136,39 +100,21 @@ impl Database { /// `user_mode` on an existing `settings` table. Kept only for the /// migration ladder. pub fn add_onboarding_columns(&self, conn: &rusqlite::Connection) -> Result<()> { - let onboarding_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='onboarding_completed'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !onboarding_exists { + if !column_exists(conn, "settings", "onboarding_completed")? { conn.execute( "ALTER TABLE settings ADD COLUMN onboarding_completed INTEGER DEFAULT 0;", (), )?; } - let evonode_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='show_evonode_tools'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !evonode_exists { + if !column_exists(conn, "settings", "show_evonode_tools")? { conn.execute( "ALTER TABLE settings ADD COLUMN show_evonode_tools INTEGER DEFAULT 0;", (), )?; } - let user_mode_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='user_mode'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !user_mode_exists { + if !column_exists(conn, "settings", "user_mode")? { conn.execute( "ALTER TABLE settings ADD COLUMN user_mode TEXT DEFAULT 'Advanced';", (), @@ -180,13 +126,7 @@ impl Database { /// Backfill `auto_start_spv` on an existing `settings` table. pub fn add_auto_start_spv_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='auto_start_spv'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !column_exists { + if !column_exists(conn, "settings", "auto_start_spv")? { conn.execute( "ALTER TABLE settings ADD COLUMN auto_start_spv INTEGER DEFAULT 0;", (), @@ -198,13 +138,7 @@ impl Database { /// Backfill `close_dash_qt_on_exit` on an existing `settings` table. pub fn add_close_dash_qt_on_exit_column(&self, conn: &rusqlite::Connection) -> Result<()> { - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('settings') WHERE name='close_dash_qt_on_exit'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !column_exists { + if !column_exists(conn, "settings", "close_dash_qt_on_exit")? { conn.execute( "ALTER TABLE settings ADD COLUMN close_dash_qt_on_exit INTEGER DEFAULT 1;", (), diff --git a/src/database/wallet.rs b/src/database/wallet.rs index 1b7007d70..8fcb7692a 100644 --- a/src/database/wallet.rs +++ b/src/database/wallet.rs @@ -1,4 +1,4 @@ -use crate::database::{CorruptedBlobError, Database}; +use crate::database::{CorruptedBlobError, Database, column_exists}; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::{ AddressInfo, ClosedKeyItem, DerivationPathReference, DerivationPathType, OpenWalletSeed, @@ -76,14 +76,7 @@ impl Database { /// Migration: Add balance columns to wallet table (version 16). pub fn add_wallet_balance_columns(&self, conn: &Connection) -> rusqlite::Result<()> { - // Check if confirmed_balance column exists - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('wallet') WHERE name='confirmed_balance'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !column_exists { + if !column_exists(conn, "wallet", "confirmed_balance")? { conn.execute( "ALTER TABLE wallet ADD COLUMN confirmed_balance INTEGER DEFAULT 0;", (), @@ -103,14 +96,7 @@ impl Database { /// Migration: Add total_received column to wallet_addresses table. pub fn add_address_total_received_column(&self, conn: &Connection) -> rusqlite::Result<()> { - // Check if total_received column exists - let column_exists: bool = conn.query_row( - "SELECT COUNT(*) FROM pragma_table_info('wallet_addresses') WHERE name='total_received'", - [], - |row| row.get::<_, i32>(0).map(|count| count > 0), - )?; - - if !column_exists { + if !column_exists(conn, "wallet_addresses", "total_received")? { conn.execute( "ALTER TABLE wallet_addresses ADD COLUMN total_received INTEGER DEFAULT 0;", (), diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs index f09f22622..3f5f8a9a5 100644 --- a/src/model/dashpay.rs +++ b/src/model/dashpay.rs @@ -1,8 +1,23 @@ //! DashPay domain types shared by the `WalletBackend` adapter, backend tasks, //! and the UI. Pure data — no I/O, no SDK calls. +use dash_sdk::dpp::document::DocumentV0Getters; +use dash_sdk::platform::{Document, Identifier}; use serde::{Deserialize, Serialize}; +/// The recipient (`toUserId`) of a DashPay `contactRequest` document. +/// +/// Returns `None` when the field is absent or does not hold a readable +/// identifier — a malformed document. Callers decide what that means for them: +/// the request lists keep such a row (they cannot prove it was resolved), while +/// the cancel path refuses to act on one. +pub fn contact_request_recipient(document: &Document) -> Option { + document + .properties() + .get("toUserId") + .and_then(|value| value.to_identifier().ok()) +} + /// DashPay profile data — the local snapshot of an identity's published profile. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StoredProfile { @@ -290,6 +305,53 @@ pub fn validate_profile_fields( #[cfg(test)] mod tests { use super::*; + use dash_sdk::dpp::document::{Document as DppDocument, DocumentV0}; + use dash_sdk::dpp::platform_value::Value; + use std::collections::BTreeMap; + + fn id(byte: u8) -> Identifier { + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") + } + + /// A `contactRequest`-shaped document. `to` of `None` omits `toUserId` + /// entirely, modelling a malformed document. + fn request_doc(to: Option) -> Document { + let mut properties = BTreeMap::new(); + if let Some(to) = to { + properties.insert("toUserId".to_string(), Value::Identifier(to.to_buffer())); + } + DppDocument::V0(DocumentV0 { + id: id(99), + owner_id: id(1), + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }) + } + + #[test] + fn contact_request_recipient_reads_the_to_user_id() { + assert_eq!( + contact_request_recipient(&request_doc(Some(id(2)))), + Some(id(2)) + ); + } + + #[test] + fn a_contact_request_without_a_recipient_is_unreadable() { + // Callers must be able to tell "recipient is X" from "cannot attribute + // this request" — the latter is what guards the cancel path. + assert_eq!(contact_request_recipient(&request_doc(None)), None); + } #[test] fn storage_key_distinguishes_outputs_of_one_tx() { diff --git a/src/ui/components/global_nav_switcher.rs b/src/ui/components/global_nav_switcher.rs index 0eb4665b3..1b7bd2028 100644 --- a/src/ui/components/global_nav_switcher.rs +++ b/src/ui/components/global_nav_switcher.rs @@ -121,11 +121,13 @@ fn gather_wallets(app_context: &Arc) -> Vec<(WalletSeedHash, String) .collect() } -/// Identity display label (Local nickname → DPNS → short id). +/// Identity display label (Local nickname → DPNS → short id). The switcher +/// reads no social profile, so the display-name tier is empty. fn identity_label(qi: &QualifiedIdentity) -> String { let dpns = qi.dpns_names.first().map(|n| n.name.as_str()); display_label( qi.alias.as_deref(), + None, dpns, &qi.identity.id().to_string(Encoding::Base58), ) diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index 22ae1ea02..f1417f99c 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -4,6 +4,7 @@ use crate::backend_task::dashpay::errors::DashPayError; use crate::backend_task::error::TaskError; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::dashpay::contact_request_recipient; use crate::model::qualified_identity::QualifiedIdentity; use crate::model::wallet::Wallet; use crate::ui::components::component_trait::Component; @@ -942,10 +943,7 @@ impl ScreenLike for ContactRequests { // Process outgoing requests for (id, doc) in outgoing.iter() { let properties = doc.properties(); - let to_identity = properties - .get("toUserId") - .and_then(|v| v.to_identifier().ok()) - .unwrap_or_default(); + let to_identity = contact_request_recipient(doc).unwrap_or_default(); let account_reference = properties .get("accountReference") diff --git a/src/ui/identity/contacts.rs b/src/ui/identity/contacts.rs index c093508f2..7f527626d 100644 --- a/src/ui/identity/contacts.rs +++ b/src/ui/identity/contacts.rs @@ -22,7 +22,8 @@ use crate::backend_task::dashpay::{ContactData, DashPayTask}; use crate::context::AppContext; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; -use crate::ui::state::contacts_view::{abbreviate_id, contact_label}; +use crate::ui::identity::identity_pill::shorten_id; +use crate::ui::state::contacts_view::contact_label; use crate::ui::theme::{ComponentStyles, DashColors, ResponseExt, Shape}; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::Identifier; @@ -122,6 +123,28 @@ pub fn contacts_button_kind(button: ContactsButton) -> ContactsButtonKind { } } +/// Render one request row and dispatch whatever the user clicked on it. +/// +/// The received and sent sections differ only in the card they hand in — the +/// row itself (render, spacing, action dispatch) is the same on both, so it +/// lives here once. +fn request_row( + ui: &mut Ui, + card: RequestCard, + entry: &ContactRequestEntry, + identity: &QualifiedIdentity, +) -> AppAction { + let response = card.show(ui); + ui.add_space(4.0); + + match response.action() { + Some(request_action) => AppAction::BackendTask(BackendTask::DashPayTask(Box::new( + request_task(request_action, identity.clone(), entry.request_id), + ))), + None => AppAction::None, + } +} + /// Backend task for a request-card action. Accept and Decline act on a received /// request; Cancel withdraws a sent one. pub fn request_task( @@ -336,21 +359,12 @@ fn received_section( } for entry in incoming { let handle = entry.counterpart_id.to_string(Encoding::Base58); - let response = RequestCard::received( - abbreviate_id(&handle), + let card = RequestCard::received( + shorten_id(&handle), &handle, entry.relative_time.as_deref().unwrap_or(""), - ) - .show(ui); - ui.add_space(4.0); - - if let Some(request_action) = response.action() { - action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new(request_task( - request_action, - identity.clone(), - entry.request_id, - )))); - } + ); + action |= request_row(ui, card, entry, identity); } }); @@ -532,16 +546,8 @@ fn sent_section( for entry in outgoing { let handle = entry.counterpart_id.to_string(Encoding::Base58); - let response = RequestCard::sent(abbreviate_id(&handle), &handle).show(ui); - ui.add_space(4.0); - - if let Some(request_action) = response.action() { - action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new(request_task( - request_action, - identity.clone(), - entry.request_id, - )))); - } + let card = RequestCard::sent(shorten_id(&handle), &handle); + action |= request_row(ui, card, entry, identity); } }); diff --git a/src/ui/identity/identity_pill.rs b/src/ui/identity/identity_pill.rs index f6daf4ad4..de01f023e 100644 --- a/src/ui/identity/identity_pill.rs +++ b/src/ui/identity/identity_pill.rs @@ -1,7 +1,9 @@ //! Identity pill — the third segment of the breadcrumb switcher. //! -//! Label priority: **Local nickname → DPNS username → shortened Identity ID** -//! (design-spec §G6). +//! Label priority: **Local nickname → DashPay display name → DPNS username → +//! shortened Identity ID** (design-spec §G6). [`display_label`] is the one +//! resolver for that rule; every surface that names an identity goes through +//! it, so the same identity never renders two different ways. //! //! Follows the project's lazy-init component pattern //! (`docs/COMPONENT_DESIGN_PATTERN.md`): domain/config fields stored on the @@ -15,26 +17,34 @@ use crate::ui::components::breadcrumb_pill::{ use crate::ui::components::component_trait::ComponentResponse; use eframe::egui::Ui; -/// Label priority resolver for an identity. Returns the first non-empty -/// option in the priority order. Never returns an empty string — an empty -/// identifier id falls back to the stable placeholder `Unknown identity`. +/// Hub-wide label priority resolver for an identity. Returns the first +/// non-empty source in priority order. Never returns an empty string — an +/// empty identifier id falls back to the stable placeholder +/// `Unknown identity`. Blank and whitespace-only sources are skipped. +/// +/// The arguments are listed in priority order: /// /// * `local_nickname` — `QualifiedIdentity.alias` in the codebase, displayed /// in the UI as "Local nickname". +/// * `display_name` — the DashPay social-profile display name. `None` on +/// surfaces that have no profile to read (e.g. the breadcrumb pill). /// * `dpns_handle` — the identity's primary DPNS username (without the /// leading `@`). /// * `identity_id_base58` — the raw Base58 identity id. Shortened to -/// `"Fx1Kj…9Tt"`-style when used as the fallback label. +/// `"Fx1Kj…9Tt"`-style by [`shorten_id`] when used as the fallback label. pub fn display_label( local_nickname: Option<&str>, + display_name: Option<&str>, dpns_handle: Option<&str>, identity_id_base58: &str, ) -> String { - if let Some(nickname) = local_nickname.map(str::trim).filter(|s| !s.is_empty()) { - return nickname.to_string(); - } - if let Some(handle) = dpns_handle.map(str::trim).filter(|s| !s.is_empty()) { - return handle.to_string(); + let named = [local_nickname, display_name, dpns_handle] + .into_iter() + .flatten() + .map(str::trim) + .find(|s| !s.is_empty()); + if let Some(name) = named { + return name.to_string(); } let trimmed = identity_id_base58.trim(); if trimmed.is_empty() { @@ -167,10 +177,12 @@ impl IdentityPill { } /// Resolve the display label using the priority rule (for tests and - /// compositional callers). + /// compositional callers). The pill carries no social-profile display + /// name, so that tier is empty here. pub fn resolved_label(&self) -> String { display_label( self.local_nickname.as_deref(), + None, self.dpns_handle.as_deref(), &self.identity_id_base58, ) @@ -217,37 +229,59 @@ mod tests { #[test] fn nickname_wins_when_present() { - let label = display_label(Some("dev"), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + let label = display_label( + Some("dev"), + Some("Alex Kim"), + Some("alex.dash"), + "Fx1Kj9TtFx1Kj9Tt", + ); assert_eq!(label, "dev"); } #[test] - fn dpns_wins_when_no_nickname() { - let label = display_label(None, Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + fn display_name_wins_when_no_nickname() { + let label = display_label( + None, + Some("Alex Kim"), + Some("alex.dash"), + "Fx1Kj9TtFx1Kj9Tt", + ); + assert_eq!(label, "Alex Kim"); + } + + #[test] + fn dpns_wins_when_no_nickname_or_display_name() { + let label = display_label(None, None, Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); assert_eq!(label, "alex.dash"); } #[test] fn empty_nickname_falls_through_to_dpns() { - let label = display_label(Some(""), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + let label = display_label(Some(""), None, Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); assert_eq!(label, "alex.dash"); } #[test] fn whitespace_nickname_falls_through_to_dpns() { - let label = display_label(Some(" "), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + let label = display_label(Some(" "), None, Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); + assert_eq!(label, "alex.dash"); + } + + #[test] + fn blank_display_name_falls_through_to_dpns() { + let label = display_label(None, Some(" "), Some("alex.dash"), "Fx1Kj9TtFx1Kj9Tt"); assert_eq!(label, "alex.dash"); } #[test] fn raw_id_fallback_when_nothing_else() { - let label = display_label(None, None, "Fx1Kj9TtFx1Kj9Tt"); + let label = display_label(None, None, None, "Fx1Kj9TtFx1Kj9Tt"); assert_eq!(label, "Fx1Kj…9Tt"); } #[test] fn short_id_not_shortened() { - let label = display_label(None, None, "abcdef"); + let label = display_label(None, None, None, "abcdef"); assert_eq!(label, "abcdef"); } @@ -266,13 +300,13 @@ mod tests { // Defensive fallback: an empty id must never produce an invisible // pill. Callers should avoid passing an empty id, but the label // resolver guards against it so a bug upstream never renders a blank. - let label = display_label(None, None, ""); + let label = display_label(None, None, None, ""); assert_eq!(label, "Unknown identity"); } #[test] fn whitespace_only_id_uses_unknown_identity_fallback() { - let label = display_label(None, None, " "); + let label = display_label(None, None, None, " "); assert_eq!(label, "Unknown identity"); } diff --git a/src/ui/state/contacts_view.rs b/src/ui/state/contacts_view.rs index 6c44bd5b7..c45c2022f 100644 --- a/src/ui/state/contacts_view.rs +++ b/src/ui/state/contacts_view.rs @@ -7,6 +7,8 @@ //! belongs in `ui/state/`. use crate::backend_task::dashpay::ContactData; +use crate::model::dashpay::contact_request_recipient; +use crate::ui::identity::identity_pill::display_label; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Document, Identifier}; @@ -122,12 +124,8 @@ impl ContactsState { self.outgoing = outgoing .into_iter() .filter_map(|(request_id, doc)| { - let counterpart_id = doc - .properties() - .get("toUserId") - .and_then(|v| v.to_identifier().ok())?; Some(ContactRequestEntry { - counterpart_id, + counterpart_id: contact_request_recipient(&doc)?, request_id, relative_time: relative_time(&doc), }) @@ -250,33 +248,17 @@ pub fn matches_contact_search(fields: ContactSearchFields<'_>, query: &str) -> b .contains(&needle) } -/// Best label for a contact row: local nickname, then DashPay display name, -/// then DPNS username, then a shortened identity ID. Mirrors the hub-wide label -/// priority rule (IDH-003). +/// Best label for a contact row. Delegates to [`display_label`], the one +/// resolver for the hub-wide priority rule (local nickname → DashPay display +/// name → DPNS username → shortened identity ID), so a contact row and an +/// identity pill can never disagree on what to call the same identity. pub fn contact_label(contact: &ContactData) -> String { - let named = [ + display_label( contact.nickname.as_deref(), contact.display_name.as_deref(), contact.username.as_deref(), - ] - .into_iter() - .flatten() - .map(str::trim) - .find(|s| !s.is_empty()); - - match named { - Some(name) => name.to_string(), - None => abbreviate_id(&contact.identity_id.to_string(Encoding::Base58)), - } -} - -/// Shorten a Base58 identity ID for display: first 8 chars + "…". -pub fn abbreviate_id(id: &str) -> String { - if id.len() <= 10 { - id.to_string() - } else { - format!("{}…", &id[..8]) - } + &contact.identity_id.to_string(Encoding::Base58), + ) } /// Pre-format a document's `created_at` as a human-relative timestamp. @@ -288,6 +270,7 @@ fn relative_time(doc: &Document) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::ui::identity::identity_pill::shorten_id; fn id(byte: u8) -> Identifier { Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") @@ -568,9 +551,10 @@ mod tests { ); // No profile at all — fall back to the shortened identity ID. + let base58 = id(7).to_string(Encoding::Base58); let fallback = contact_label(&contact(None, None, None)); - assert_eq!(fallback, abbreviate_id(&id(7).to_string(Encoding::Base58))); - assert!(fallback.ends_with('…')); + assert_eq!(fallback, shorten_id(&base58)); + assert!(fallback.contains('…')); } #[test] @@ -583,9 +567,37 @@ mod tests { } #[test] - fn abbreviate_id_shortens_long_ids_only() { - assert_eq!(abbreviate_id("AbCdEfGhIjKlMnOpQrStUv"), "AbCdEfGh…"); - assert_eq!(abbreviate_id("AbCdEfGh"), "AbCdEfGh"); - assert_eq!(abbreviate_id(""), ""); + fn a_profileless_contact_is_shortened_exactly_like_its_identity_pill() { + // One identity must not render two different ways depending on the tab. + // The contacts list and the identity pill share `shorten_id`, so the + // same id shortens to the same string on both surfaces. + let base58 = id(7).to_string(Encoding::Base58); + assert_eq!( + contact_label(&contact(None, None, None)), + shorten_id(&base58), + ); + } + + #[test] + fn a_contacts_label_is_the_hub_wide_label_for_the_same_identity() { + // `contact_label` must stay a pure delegation to `display_label` — the + // whole point of the shared resolver is that no tier can drift. + for (nickname, display, username) in [ + (Some("Bao"), Some("Alex Kim"), Some("alex.dash")), + (None, Some("Alex Kim"), Some("alex.dash")), + (None, None, Some("alex.dash")), + (None, None, None), + ] { + let c = contact(nickname, display, username); + assert_eq!( + contact_label(&c), + display_label( + nickname, + display, + username, + &c.identity_id.to_string(Encoding::Base58) + ), + ); + } } } From 0fcb6e7e654fc6cc23da91f96cbae5e9f17d2c85 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:47:52 +0000 Subject: [PATCH 17/30] test(migration): lock the v0.9.3 -> v1.0 upgrade path end to end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/migration/mod.rs | 5 + src/backend_task/migration/v093_upgrade.rs | 754 +++++++++++++++++++++ 2 files changed, 759 insertions(+) create mode 100644 src/backend_task/migration/v093_upgrade.rs diff --git a/src/backend_task/migration/mod.rs b/src/backend_task/migration/mod.rs index fef08a8f6..935959ae0 100644 --- a/src/backend_task/migration/mod.rs +++ b/src/backend_task/migration/mod.rs @@ -20,6 +20,11 @@ pub mod finish_unwire; pub mod legacy_settings; pub mod single_key_restore; +/// Cross-subsystem upgrade regression from a real v0.9.3 `data.db`: schema +/// ladder, boot settings import and wallet drain, composed in production order. +#[cfg(test)] +mod v093_upgrade; + pub use finish_unwire::MigrationError; /// Migration orchestrator dispatch enum. Cheap to clone — every diff --git a/src/backend_task/migration/v093_upgrade.rs b/src/backend_task/migration/v093_upgrade.rs new file mode 100644 index 000000000..dee54fb16 --- /dev/null +++ b/src/backend_task/migration/v093_upgrade.rs @@ -0,0 +1,754 @@ +//! End-to-end upgrade regression from a genuine **v0.9.3** `data.db`. +//! +//! v0.9.3 is the newest released build, so its on-disk shape is what every +//! upgrading user actually hands to v1.0. The three subsystems that carry that +//! data across each have their own unit tests, but each 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 they **compose** +//! from real v0.9.3 raw data, in the order `AppState` actually runs them: +//! +//! 1. [`Database::initialize`] — the schema ladder, v11 → current. +//! 2. [`import_legacy_settings`] — user preferences, at boot, **before** the +//! active network is chosen (`AppState::new_inner`). +//! 3. [`finish_unwire::run`] — the wallet drain plus the scheduled-vote and +//! top-up import, on the network step 2 selected. +//! +//! The fixture is the v0.9.3 schema verbatim (`git show v0.9.3:src/database/`), +//! not the modern one: `database_version = 11`, no `single_key_wallet` table, +//! no `core_wallet_name` column, no `onboarding_completed` column, and seeds +//! stored raw with empty salt/nonce. + +use std::sync::Arc; + +use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; +use dash_sdk::platform::Identifier; +use rusqlite::{Connection, params}; + +use crate::backend_task::migration::finish_unwire::{self, MigrationCompletion, sentinel_key_for}; +use crate::backend_task::migration::legacy_settings::{SettingsImport, import_legacy_settings}; +use crate::context::AppContext; +use crate::database::Database; +use crate::database::test_helpers::{create_database_at_path, legacy_master_epk_bytes}; +use crate::model::settings::{AppSettings, RootScreenType, ThemeMode}; +use crate::model::wallet::encryption::encrypt_message; +use crate::model::wallet::{ClosedKeyItem, WalletSeedHash}; +use crate::wallet_backend::secret_seam::SecretScheme; +use crate::wallet_backend::{DetScope, WalletBackend}; + +/// `DEFAULT_DB_VERSION` as shipped by v0.9.3. Every upgrading user's `data.db` +/// enters the ladder here. +const V093_DB_VERSION: u16 = 11; + +/// The network the fixture user runs on. The regression this file locks: a +/// v0.9.3 testnet user must not be relaunched on mainnet. +const USER_NETWORK: Network = Network::Testnet; + +const UNPROTECTED_SEED: [u8; 64] = [0xA9; 64]; +const PROTECTED_SEED: [u8; 64] = [0xC7; 64]; +const PROTECTED_PASSWORD: &str = "correct horse battery staple"; +const IDENTITY_ID: [u8; 32] = [0xBB; 32]; +const TOP_UP_AMOUNT: u64 = 123_456; +const CONTESTED_NAME: &str = "quantum"; + +/// The seed vault + metadata sidecar state of the two migrated wallets, plus +/// the envelope bytes the fixture wrote, so a test can assert the protected +/// envelope travelled byte-for-byte. +struct Fixture { + unprotected: WalletSeedHash, + protected: WalletSeedHash, + /// Exactly what the v0.9.3 `wallet` row holds for the protected wallet: + /// AES-256-GCM ciphertext, 16-byte Argon2 salt, 12-byte GCM nonce. + protected_ciphertext: Vec, + protected_salt: Vec, + protected_nonce: Vec, +} + +/// Write a `data.db` in the exact shape v0.9.3 left on disk, then hand back the +/// keys the assertions need. +/// +/// The DDL is copied from `git show v0.9.3:src/database/{initialization, +/// scheduled_votes,top_ups,tokens,proof_log}.rs` — a v0.9.3 `create_tables()` +/// builds every one of these, so a real install has them all even if it never +/// walked a migration. Deliberately absent: `single_key_wallet` (introduced by +/// ladder arm 18 — the feature did not exist in v0.9.3) and +/// `wallet.core_wallet_name` (arm 33). +fn write_v093_database(dir: &std::path::Path) -> Fixture { + let conn = Connection::open(dir.join("data.db")).expect("create legacy data.db"); + conn.execute_batch( + "CREATE TABLE settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + password_check BLOB, + main_password_salt BLOB, + main_password_nonce BLOB, + network TEXT NOT NULL, + start_root_screen INTEGER NOT NULL, + custom_dash_qt_path TEXT, + overwrite_dash_conf INTEGER, + theme_preference TEXT DEFAULT 'System', + database_version INTEGER NOT NULL + ); + + CREATE TABLE wallet ( + seed_hash BLOB NOT NULL PRIMARY KEY, + encrypted_seed BLOB NOT NULL, + salt BLOB NOT NULL, + nonce BLOB NOT NULL, + master_ecdsa_bip44_account_0_epk BLOB NOT NULL, + alias TEXT, + is_main INTEGER, + uses_password INTEGER NOT NULL, + password_hint TEXT, + network TEXT NOT NULL + ); + + CREATE TABLE wallet_addresses ( + seed_hash BLOB NOT NULL, + address TEXT NOT NULL, + derivation_path TEXT NOT NULL, + balance INTEGER, + path_reference INTEGER NOT NULL, + path_type INTEGER NOT NULL, + PRIMARY KEY (seed_hash, address), + FOREIGN KEY (seed_hash) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE TABLE utxos ( + txid BLOB NOT NULL, + vout INTEGER NOT NULL, + address TEXT NOT NULL, + value INTEGER NOT NULL, + script_pubkey BLOB NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY (txid, vout, network) + ); + + CREATE TABLE asset_lock_transaction ( + tx_id BLOB PRIMARY KEY, + transaction_data BLOB NOT NULL, + amount INTEGER, + instant_lock_data BLOB, + chain_locked_height INTEGER, + identity_id BLOB, + identity_id_potentially_in_creation BLOB, + wallet BLOB NOT NULL, + network TEXT NOT NULL, + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE SET NULL, + FOREIGN KEY (identity_id_potentially_in_creation) REFERENCES identity(id) ON DELETE SET NULL, + FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE TABLE identity ( + id BLOB PRIMARY KEY, + data BLOB, + status INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL, + alias TEXT, + info TEXT, + wallet BLOB, + wallet_index INTEGER, + identity_type TEXT, + network TEXT NOT NULL, + CHECK ((wallet IS NOT NULL AND wallet_index IS NOT NULL) + OR (wallet IS NULL AND wallet_index IS NULL)), + FOREIGN KEY (wallet) REFERENCES wallet(seed_hash) ON DELETE CASCADE + ); + + CREATE TABLE contested_name ( + normalized_contested_name TEXT NOT NULL, + locked_votes INTEGER, + abstain_votes INTEGER, + awarded_to BLOB, + end_time INTEGER, + locked INTEGER NOT NULL DEFAULT 0, + last_updated INTEGER, + network TEXT NOT NULL, + PRIMARY KEY (normalized_contested_name, network) + ); + + CREATE TABLE contestant ( + normalized_contested_name TEXT NOT NULL, + identity_id BLOB NOT NULL, + name TEXT, + votes INTEGER, + created_at INTEGER, + created_at_block_height INTEGER, + created_at_core_block_height INTEGER, + document_id BLOB, + network TEXT NOT NULL, + PRIMARY KEY (normalized_contested_name, identity_id, network), + FOREIGN KEY (normalized_contested_name, network) + REFERENCES contested_name(normalized_contested_name, network) ON DELETE CASCADE + ); + + CREATE TABLE contract ( + contract_id BLOB, + contract BLOB, + alias TEXT, + network TEXT NOT NULL, + PRIMARY KEY (contract_id, network) + ); + + CREATE TABLE proof_log ( + proof_id INTEGER PRIMARY KEY AUTOINCREMENT, + request_type INTEGER NOT NULL, + request_bytes BLOB NOT NULL, + path_query_bytes BLOB NOT NULL, + height INTEGER NOT NULL, + time_ms INTEGER NOT NULL, + proof_bytes BLOB NOT NULL, + error TEXT + ); + + CREATE TABLE top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (identity_id, top_up_index), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE + ); + + CREATE TABLE scheduled_votes ( + identity_id BLOB NOT NULL, + contested_name TEXT NOT NULL, + vote_choice TEXT NOT NULL, + time INTEGER NOT NULL, + executed INTEGER NOT NULL DEFAULT 0, + network TEXT NOT NULL, + PRIMARY KEY (identity_id, contested_name), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE + ); + + CREATE TABLE token ( + id BLOB PRIMARY KEY, + token_alias TEXT NOT NULL, + token_config BLOB NOT NULL, + data_contract_id BLOB NOT NULL, + token_position INTEGER NOT NULL, + network TEXT NOT NULL, + FOREIGN KEY (data_contract_id, network) + REFERENCES contract(contract_id, network) ON DELETE CASCADE + ); + + CREATE TABLE identity_token_balances ( + token_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + balance INTEGER NOT NULL, + network TEXT NOT NULL, + PRIMARY KEY(token_id, identity_id, network), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE, + FOREIGN KEY (token_id) REFERENCES token(id) ON DELETE CASCADE + ); + + CREATE TABLE identity_order ( + pos INTEGER NOT NULL, + identity_id BLOB NOT NULL, + PRIMARY KEY(pos), + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE + ); + + CREATE TABLE token_order ( + pos INTEGER NOT NULL, + token_id BLOB NOT NULL, + identity_id BLOB NOT NULL, + PRIMARY KEY(pos, token_id), + FOREIGN KEY (token_id) REFERENCES token(id) ON DELETE CASCADE, + FOREIGN KEY (identity_id) REFERENCES identity(id) ON DELETE CASCADE + );", + ) + .expect("create v0.9.3 schema"); + + // A testnet user with a dark theme who parked on the scheduled-votes screen. + // `start_root_screen = 10` means the same screen in v0.9.3 and today, so it + // is a value that genuinely round-trips rather than a coincidence. + conn.execute( + "INSERT INTO settings + (id, network, start_root_screen, custom_dash_qt_path, overwrite_dash_conf, + theme_preference, database_version) + VALUES (1, ?1, ?2, '/opt/dash-qt', 0, 'Dark', ?3)", + params![ + USER_NETWORK.to_string(), + RootScreenType::RootScreenDPNSScheduledVotes.to_int(), + V093_DB_VERSION, + ], + ) + .expect("insert settings row"); + + // Unprotected wallet: v0.9.3 stores the raw 64-byte seed with EMPTY salt and + // nonce (`add_new_wallet_screen.rs`: `(seed.to_vec(), vec![], vec![], false)`). + let unprotected = ClosedKeyItem::compute_seed_hash(&UNPROTECTED_SEED); + conn.execute( + "INSERT INTO wallet + (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, + alias, is_main, uses_password, password_hint, network) + VALUES (?1, ?2, ?3, ?4, ?5, 'Masternode Owner Wallet', 1, 0, NULL, ?6)", + params![ + unprotected.as_slice(), + UNPROTECTED_SEED.as_slice(), + Vec::::new(), + Vec::::new(), + legacy_master_epk_bytes(&UNPROTECTED_SEED, USER_NETWORK), + USER_NETWORK.to_string(), + ], + ) + .expect("insert unprotected wallet row"); + + // Protected wallet: the legacy Argon2 + AES-256-GCM envelope, produced by the + // very function v0.9.3 used, so the bytes under test are real ciphertext. + let envelope = encrypt_message(&PROTECTED_SEED, PROTECTED_PASSWORD).expect("encrypt seed"); + let protected = ClosedKeyItem::compute_seed_hash(&PROTECTED_SEED); + conn.execute( + "INSERT INTO wallet + (seed_hash, encrypted_seed, salt, nonce, master_ecdsa_bip44_account_0_epk, + alias, is_main, uses_password, password_hint, network) + VALUES (?1, ?2, ?3, ?4, ?5, 'Cold Storage', 0, 1, 'the usual', ?6)", + params![ + protected.as_slice(), + envelope.ciphertext.as_slice(), + envelope.salt.as_slice(), + envelope.nonce.as_slice(), + legacy_master_epk_bytes(&PROTECTED_SEED, USER_NETWORK), + USER_NETWORK.to_string(), + ], + ) + .expect("insert protected wallet row"); + + // A masternode identity owned by the unprotected wallet. `data` is the opaque + // bincode blob v0.9.3 wrote (`QualifiedIdentity::to_bytes`); no current code + // path decodes it — see the module note on the identity-import gap. + conn.execute( + "INSERT INTO identity + (id, data, status, is_local, alias, wallet, wallet_index, identity_type, network) + VALUES (?1, ?2, 0, 1, 'my-masternode', ?3, 0, 'Masternode', ?4)", + params![ + IDENTITY_ID.as_slice(), + vec![0u8; 16], + unprotected.as_slice(), + USER_NETWORK.to_string(), + ], + ) + .expect("insert identity row"); + + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, ?2, 'Lock', 1700000000, 0, ?3)", + params![ + IDENTITY_ID.as_slice(), + CONTESTED_NAME, + USER_NETWORK.to_string() + ], + ) + .expect("insert scheduled vote row"); + + conn.execute( + "INSERT INTO top_up (identity_id, top_up_index, amount) VALUES (?1, 0, ?2)", + params![IDENTITY_ID.as_slice(), TOP_UP_AMOUNT], + ) + .expect("insert top-up row"); + + Fixture { + unprotected, + protected, + protected_ciphertext: envelope.ciphertext, + protected_salt: envelope.salt, + protected_nonce: envelope.nonce, + } +} + +/// Boot over `dir` exactly as `AppState` does: run the ladder, import the legacy +/// preferences, then build the `AppContext` **on the network those preferences +/// named**. Returns the context and the imported settings blob. +/// +/// Taking the network from the import (rather than hard-coding testnet) is the +/// point: it is what makes this a composition test. If the import lost the +/// network, every downstream `WHERE network = ?1` filter in the wallet drain +/// would silently target mainnet and find nothing. +fn boot(dir: &std::path::Path) -> (Arc, AppSettings) { + crate::app_dir::ensure_env_file(dir); + let db_file = dir.join("data.db"); + + let db = Arc::new(Database::new(&db_file).expect("open data.db")); + db.initialize(&db_file) + .expect("schema ladder v11 -> current"); + + let app_kv = AppContext::open_app_kv(dir).expect("open app k/v"); + let outcome = import_legacy_settings(&app_kv, &db).expect("import legacy settings"); + assert_eq!( + outcome, + SettingsImport::Imported { + network: USER_NETWORK + }, + "the boot import must report the network it restored", + ); + + let settings = app_kv + .get::(DetScope::Global, AppSettings::KV_KEY) + .expect("read settings blob") + .expect("the import must write a settings blob"); + + let secret_store = AppContext::open_secret_store(dir).expect("open secret store"); + let ctx = AppContext::new( + dir.to_path_buf(), + settings.network, + db, + Default::default(), + Default::default(), + egui::Context::default(), + app_kv, + secret_store, + Arc::new(std::sync::atomic::AtomicBool::new(false)), + ) + .expect("AppContext"); + + (ctx, settings) +} + +/// Wire the real wallet seam offline — the backend builds and hydrates its +/// sidecars without touching the network. +async fn wire_backend(ctx: &Arc) -> Arc { + let (tx, _rx) = tokio::sync::mpsc::channel::(32); + let sender = crate::utils::egui_mpsc::SenderAsync::new(tx, ctx.egui_ctx().clone()); + ctx.ensure_wallet_backend(sender) + .await + .expect("wallet backend must wire offline"); + ctx.wallet_backend().expect("backend wired") +} + +fn schema_version_at(db_file: &std::path::Path) -> u16 { + Connection::open(db_file) + .expect("open database file") + .query_row( + "SELECT database_version FROM settings WHERE id = 1", + [], + |r| r.get(0), + ) + .expect("read schema version") +} + +fn schema_version(dir: &std::path::Path) -> u16 { + schema_version_at(&dir.join("data.db")) +} + +/// The schema version a **fresh** install lands on. Asserting the upgraded DB +/// matches this — rather than a hard-coded number — states the real contract +/// ("an upgraded v0.9.3 install is schema-identical to a new one") and cannot +/// drift when the ladder grows another arm. +fn fresh_install_schema_version() -> u16 { + let dir = tempfile::tempdir().expect("tempdir"); + let db_file = dir.path().join("fresh.db"); + create_database_at_path(&db_file).expect("fresh install database"); + schema_version_at(&db_file) +} + +fn top_up_history(ctx: &Arc) -> Option> { + // `det:top_ups:v1` is `context::identity_db::TOP_UPS_KEY`, which is private to + // that module. A key change surfaces here as a missing entry, not a silent pass. + ctx.det_kv() + .expect("per-network k/v") + .get::>( + DetScope::Identity(&IDENTITY_ID), + "det:top_ups:v1", + ) + .expect("read top-up history") +} + +/// The full upgrade: a v0.9.3 install boots on v1.0 and finds everything where +/// it left it — funds, wallet names, the network it runs on, its queued vote and +/// its top-up history. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn v093_install_upgrades_with_wallets_settings_votes_and_history_intact() { + let tmp = tempfile::tempdir().expect("tempdir"); + let fixture = write_v093_database(tmp.path()); + assert_eq!( + schema_version(tmp.path()), + V093_DB_VERSION, + "precondition: the fixture is a v0.9.3-shaped database", + ); + + let (ctx, settings) = boot(tmp.path()); + let backend = wire_backend(&ctx).await; + + assert!( + finish_unwire::run(&ctx).await.expect("migration"), + "a v0.9.3 install has data to move, so the launch must report work done", + ); + + // ── Schema ladder ──────────────────────────────────────────────── + assert_eq!( + schema_version(tmp.path()), + fresh_install_schema_version(), + "the ladder must walk v11 all the way to the current version", + ); + + // ── Settings: the safety-critical field ────────────────────────── + assert_eq!( + settings.network, + Network::Testnet, + "a v0.9.3 testnet user must not be silently relaunched on mainnet", + ); + assert_eq!( + settings.theme_mode, + ThemeMode::Dark, + "the chosen theme must survive" + ); + assert_eq!( + settings.root_screen_type, + RootScreenType::RootScreenDPNSScheduledVotes, + "the start screen must survive", + ); + assert_eq!( + settings.dash_qt_path, + Some(std::path::PathBuf::from("/opt/dash-qt")), + "the configured Dash-Qt path must survive", + ); + assert!( + !settings.overwrite_dash_conf, + "an explicit `false` must not be flipped back to the default `true`", + ); + // v0.9.3's `settings` table has NO `onboarding_completed` column and no ladder + // arm adds one, so it can only fall back to the default — the documented + // contract of `read_app_settings` for columns an older schema predates. + assert_eq!( + settings.onboarding_completed, + AppSettings::default().onboarding_completed, + "a column v0.9.3 never had must fall back to its default, not fail the read", + ); + + // ── Wallet seeds: the funds path ───────────────────────────────── + // The drain copies each legacy envelope into the vault; hydration then + // promotes the unprotected one to the raw seam (`seed.raw.v1`) and drops the + // legacy row. So the seed is asserted where it actually ends up — as the raw + // 64 bytes the wallet is made of. + let seeds = backend.wallet_seeds(); + let raw_seed = seeds + .get_raw(&fixture.unprotected) + .expect("read unprotected seed") + .expect("the unprotected seed must be readable from the vault after the upgrade"); + assert_eq!( + raw_seed.as_slice(), + UNPROTECTED_SEED.as_slice(), + "the seed bytes are the wallet — they must arrive verbatim", + ); + assert_eq!( + seeds.scheme(&fixture.unprotected).expect("scheme"), + SecretScheme::Unprotected, + "a wallet the user never password-protected must not gain a password it cannot supply", + ); + assert!( + seeds + .legacy_envelope_get(&fixture.unprotected) + .expect("read legacy envelope") + .is_none(), + "the promoted legacy envelope must be dropped, not left as a second at-rest copy", + ); + + // The protected wallet cannot be promoted — that needs the user's password — + // so its legacy envelope stays put and must be byte-identical to what v0.9.3 + // wrote. Re-encrypting or truncating it would lock the user out permanently. + let protected = seeds + .legacy_envelope_get(&fixture.protected) + .expect("read protected envelope") + .expect("the protected seed must reach the vault"); + assert_eq!( + ( + protected.encrypted_seed, + protected.salt, + protected.nonce, + protected.uses_password, + protected.password_hint + ), + ( + fixture.protected_ciphertext, + fixture.protected_salt, + fixture.protected_nonce, + true, + Some("the usual".to_string()) + ), + "the legacy AES-GCM envelope must be copied byte-for-byte", + ); + assert_eq!( + seeds.scheme(&fixture.protected).expect("scheme"), + SecretScheme::Absent, + "a locked wallet must stay locked — no silent unseal of a protected seed", + ); + + // ── Wallet metadata + registration ─────────────────────────────── + let meta_view = backend.wallet_meta(); + let meta = meta_view + .get(USER_NETWORK, &fixture.unprotected) + .expect("the migrated wallet must have a metadata entry"); + assert_eq!( + meta.alias, "Masternode Owner Wallet", + "the name the user chose must survive the upgrade", + ); + assert!(meta.is_main, "the main-wallet flag must survive"); + assert!(!meta.uses_password); + assert_eq!( + meta.xpub_encoded, + legacy_master_epk_bytes(&UNPROTECTED_SEED, USER_NETWORK), + "the master xpub must survive — the cold-boot picker renders addresses from it", + ); + assert!( + backend.is_wallet_registered(&fixture.unprotected), + "the open wallet must be reachable upstream once the migration completes", + ); + + let protected_meta = meta_view + .get(USER_NETWORK, &fixture.protected) + .expect("the protected wallet must have a metadata entry"); + assert_eq!(protected_meta.alias, "Cold Storage"); + assert!( + protected_meta.uses_password, + "the protected wallet must stay marked protected, or the unlock prompt never appears", + ); + assert_eq!(protected_meta.password_hint.as_deref(), Some("the usual")); + + // ── Single keys: a feature v0.9.3 never had ────────────────────── + // Ladder arm 18 creates the table, so post-upgrade it exists and is empty. The + // drain must read zero rows and report no error — `run()` returning `Ok` above + // is that proof; nothing may be conjured into the modern index either. + assert!( + backend.single_key().list().is_empty(), + "a v0.9.3 install has no single keys, so none may appear after the upgrade", + ); + + // ── Scheduled votes ────────────────────────────────────────────── + let votes = ctx.get_scheduled_votes().expect("read scheduled votes"); + assert_eq!(votes.len(), 1, "the queued vote must come across"); + assert_eq!(votes[0].contested_name, CONTESTED_NAME); + assert_eq!(votes[0].choice, ResourceVoteChoice::Lock); + assert_eq!(votes[0].voter_id, Identifier::from(IDENTITY_ID)); + assert!( + !votes[0].executed_successfully, + "an uncast vote must not arrive marked as cast — that would skip the vote window", + ); + + // ── Top-up history ─────────────────────────────────────────────── + assert_eq!( + top_up_history(&ctx), + Some(std::collections::BTreeMap::from([(0, TOP_UP_AMOUNT)])), + "the top-up audit trail must come across, scoped by its identity's network", + ); + + // ── Identity ───────────────────────────────────────────────────── + // The identity row survives the ladder with its wallet link intact. It is the + // join the top-up import scopes on, and the source a future identity importer + // reads (see the module note): the ladder must not drop or orphan it. + let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); + let (alias, wallet, wallet_index, identity_type, network): ( + String, + Vec, + u32, + String, + String, + ) = conn + .query_row( + "SELECT alias, wallet, wallet_index, identity_type, network + FROM identity WHERE id = ?1", + params![IDENTITY_ID.as_slice()], + |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)), + ) + .expect("the identity row must survive the ladder"); + assert_eq!(alias, "my-masternode"); + assert_eq!( + wallet, + fixture.unprotected.to_vec(), + "the identity must stay linked to the wallet that owns it", + ); + assert_eq!(wallet_index, 0); + assert_eq!(identity_type, "Masternode"); + assert_eq!( + network, "testnet", + "a testnet identity must not be swept into mainnet by the v33 network rename", + ); + + backend.shutdown().await; +} + +/// The second launch after an upgrade. Every step must short-circuit on its +/// sentinel: no duplicated votes, no resurrected history, no rewritten sentinel, +/// no clobbered preferences — and the legacy rows still in `data.db`, because a +/// migration that deletes its source can never be retried. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn second_launch_after_a_v093_upgrade_changes_nothing() { + let tmp = tempfile::tempdir().expect("tempdir"); + let fixture = write_v093_database(tmp.path()); + + let (ctx, _) = boot(tmp.path()); + let backend = wire_backend(&ctx).await; + finish_unwire::run(&ctx).await.expect("first migration"); + + let sentinel_after_first = ctx + .app_kv() + .get::(DetScope::Global, &sentinel_key_for(USER_NETWORK)) + .expect("read sentinel") + .expect("the first launch must record completion"); + + // The user switches to mainnet and casts the queued vote. Both are choices a + // re-import would undo. + let app_kv = ctx.app_kv(); + let mut chosen = app_kv + .get::(DetScope::Global, AppSettings::KV_KEY) + .expect("read settings") + .expect("settings blob"); + chosen.network = Network::Mainnet; + app_kv + .put(DetScope::Global, AppSettings::KV_KEY, &chosen) + .expect("user switches network"); + ctx.clear_all_scheduled_votes() + .expect("user casts the vote"); + + // Second launch: the boot import runs again, then the migration. + assert_eq!( + import_legacy_settings(&app_kv, &ctx.db).expect("second settings import"), + SettingsImport::AlreadyDone, + "the settings sentinel must stop the import from running twice", + ); + assert!( + !finish_unwire::run(&ctx).await.expect("second migration"), + "a second launch must move no data", + ); + + assert_eq!( + app_kv + .get::(DetScope::Global, AppSettings::KV_KEY) + .expect("read settings") + .expect("settings blob") + .network, + Network::Mainnet, + "a re-import must not resurrect the legacy network over the user's choice", + ); + assert!( + ctx.get_scheduled_votes().expect("read votes").is_empty(), + "a re-run must not requeue a vote the user has already cast", + ); + assert_eq!( + ctx.app_kv() + .get::(DetScope::Global, &sentinel_key_for(USER_NETWORK)) + .expect("read sentinel") + .expect("sentinel still present"), + sentinel_after_first, + "a no-op launch must not rewrite the completion sentinel", + ); + assert!( + backend.is_wallet_registered(&fixture.unprotected), + "the migrated wallet must stay reachable across launches", + ); + + // The migration never deletes its source, so a later build can re-read it. + let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); + let wallets: i64 = conn + .query_row("SELECT COUNT(*) FROM wallet", [], |r| r.get(0)) + .expect("count wallet rows"); + let votes: i64 = conn + .query_row("SELECT COUNT(*) FROM scheduled_votes", [], |r| r.get(0)) + .expect("count vote rows"); + assert_eq!( + (wallets, votes), + (2, 1), + "legacy rows must survive untouched" + ); + + backend.shutdown().await; +} From 771b9d72d5d2ca1c50f74f93c29b69437f6ebdd1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:49:46 +0000 Subject: [PATCH 18/30] fix(tokens): stop concurrent dismissals from clobbering each other MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::`). 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 --- src/context/contract_token_db.rs | 344 ++++++++++++++++++++++++------- 1 file changed, 274 insertions(+), 70 deletions(-) diff --git a/src/context/contract_token_db.rs b/src/context/contract_token_db.rs index 3965266a2..4631a24a2 100644 --- a/src/context/contract_token_db.rs +++ b/src/context/contract_token_db.rs @@ -38,10 +38,36 @@ const TOKEN_KEY_PREFIX: &str = "det:token:"; /// k/v store, so each holds its own ordering). const TOKEN_ORDER_KEY: &str = "det:token_order:v1"; -/// Versioned key for the `(token_id, identity_id)` pairs the user stopped -/// tracking. Upstream's token watch set is in-memory only, so the dismissal -/// is persisted here and re-applied every time DET rebuilds a watch set. -const TOKEN_UNTRACKED_KEY: &str = "det:token_untracked:v1"; +/// Key prefix for the `(token_id, identity_id)` pairs the user stopped +/// tracking. Upstream's token watch set is in-memory only, so the dismissal is +/// persisted here and re-applied every time DET rebuilds a watch set. +/// +/// One presence marker per pair at +/// `det:token_untracked:v2::`; the value is +/// empty, only the key carries meaning. Dismiss and re-track run as independent +/// backend tasks that can overlap, so a marker per pair (rather than one +/// set-valued blob) keeps each mutation a single self-contained write that no +/// concurrent mutation can read stale and clobber. Token id leads, so dropping +/// every dismissal of one token is a single prefix scan. +const TOKEN_UNTRACKED_PREFIX: &str = "det:token_untracked:v2:"; + +/// Marker key for one dismissed `(token, identity)` pair. +fn untracked_key(pair: &IdentityTokenIdentifier) -> String { + format!( + "{}{}", + untracked_token_prefix(&pair.token_id), + pair.identity_id.to_string(Encoding::Base58) + ) +} + +/// Key prefix covering every identity that dismissed `token_id`. +fn untracked_token_prefix(token_id: &Identifier) -> String { + format!( + "{}{}:", + TOKEN_UNTRACKED_PREFIX, + token_id.to_string(Encoding::Base58) + ) +} fn contract_key(contract_id: &Identifier) -> String { format!( @@ -408,12 +434,7 @@ impl AppContext { &self, pair: IdentityTokenIdentifier, ) -> std::result::Result<(), TaskError> { - let kv = self.det_kv()?; - let mut pairs = read_untracked(&kv)?; - if pairs.insert(pair) { - write_untracked(&kv, &pairs)?; - } - Ok(()) + mark_untracked_in(&self.det_kv()?, pair) } /// Track a previously dismissed identity-token pair again. @@ -422,12 +443,7 @@ impl AppContext { &self, pair: IdentityTokenIdentifier, ) -> std::result::Result<(), TaskError> { - let kv = self.det_kv()?; - let mut pairs = read_untracked(&kv)?; - if pairs.remove(&pair) { - write_untracked(&kv, &pairs)?; - } - Ok(()) + clear_untracked_in(&self.det_kv()?, pair) } /// Track a token again for every identity that dismissed it. Idempotent. @@ -654,17 +670,11 @@ impl AppContext { return Ok(()); } let kv = self.det_kv()?; - let registry_keys = kv - .list(DetScope::Global, Some(TOKEN_KEY_PREFIX)) - .map_err(token_err)?; - for key in registry_keys { - kv.delete(DetScope::Global, &key).map_err(token_err)?; - } + delete_by_prefix(&kv, TOKEN_KEY_PREFIX)?; + delete_by_prefix(&kv, TOKEN_UNTRACKED_PREFIX)?; // Balances are owned upstream now — nothing local to wipe. kv.delete(DetScope::Global, TOKEN_ORDER_KEY) .map_err(token_err)?; - kv.delete(DetScope::Global, TOKEN_UNTRACKED_KEY) - .map_err(token_err)?; Ok(()) } @@ -712,49 +722,69 @@ fn decode_token_config(bytes: &[u8]) -> std::result::Result std::result::Result, TaskError> { - let Some(payload): Option> = kv - .get(DetScope::Global, TOKEN_UNTRACKED_KEY) - .map_err(token_err)? - else { - return Ok(BTreeSet::new()); - }; - Ok(payload - .into_iter() - .map(|(token, identity)| IdentityTokenIdentifier { - token_id: Identifier::from(token), - identity_id: Identifier::from(identity), - }) - .collect()) + let keys = kv + .list(DetScope::Global, Some(TOKEN_UNTRACKED_PREFIX)) + .map_err(token_err)?; + let mut pairs = BTreeSet::new(); + for key in keys { + match parse_untracked_key(&key) { + Some(pair) => { + pairs.insert(pair); + } + None => tracing::warn!(key = %key, "Skipping unparseable token dismissal key"), + } + } + Ok(pairs) +} + +/// Decode a marker key back into the pair it dismisses. The inverse of +/// [`untracked_key`] — the sole place the `:` key layout is +/// mapped back onto the named fields. +fn parse_untracked_key(key: &str) -> Option { + let (token, identity) = key.strip_prefix(TOKEN_UNTRACKED_PREFIX)?.split_once(':')?; + Some(IdentityTokenIdentifier { + token_id: Identifier::from_string(token, Encoding::Base58).ok()?, + identity_id: Identifier::from_string(identity, Encoding::Base58).ok()?, + }) } -fn write_untracked( +/// Dismiss one pair. A single upsert of that pair's marker — idempotent, and +/// independent of every other pair's marker. +fn mark_untracked_in( kv: &DetKv, - pairs: &BTreeSet, + pair: IdentityTokenIdentifier, ) -> std::result::Result<(), TaskError> { - let payload: Vec<([u8; 32], [u8; 32])> = pairs - .iter() - .map(|pair| (pair.token_id.to_buffer(), pair.identity_id.to_buffer())) - .collect(); - kv.put(DetScope::Global, TOKEN_UNTRACKED_KEY, &payload) + kv.put(DetScope::Global, &untracked_key(&pair), &()) + .map_err(token_err) +} + +/// Re-track one pair. A single delete of that pair's marker — idempotent, and +/// independent of every other pair's marker. +fn clear_untracked_in( + kv: &DetKv, + pair: IdentityTokenIdentifier, +) -> std::result::Result<(), TaskError> { + kv.delete(DetScope::Global, &untracked_key(&pair)) .map_err(token_err) } /// Drop every dismissal recorded for `token_id`, whichever identity made it. -/// Writes back only when the set actually shrinks. fn clear_untracked_token_in( kv: &DetKv, token_id: &Identifier, ) -> std::result::Result<(), TaskError> { - let mut pairs = read_untracked(kv)?; - let before = pairs.len(); - pairs.retain(|pair| pair.token_id != *token_id); - if pairs.len() != before { - write_untracked(kv, &pairs)?; + delete_by_prefix(kv, &untracked_token_prefix(token_id)) +} + +/// Delete every Global-scoped key under `prefix`. +fn delete_by_prefix(kv: &DetKv, prefix: &str) -> std::result::Result<(), TaskError> { + for key in kv.list(DetScope::Global, Some(prefix)).map_err(token_err)? { + kv.delete(DetScope::Global, &key).map_err(token_err)?; } Ok(()) } @@ -792,7 +822,8 @@ where mod tests { use super::*; use crate::wallet_backend::kv_test_support::InMemoryKv; - use std::sync::Arc; + use platform_wallet_storage::{KvError, KvStore, ObjectId}; + use std::sync::{Arc, Mutex}; fn empty_kv() -> DetKv { DetKv::from_store(Arc::new(InMemoryKv::default())) @@ -936,32 +967,67 @@ mod tests { assert!(read_untracked(&kv).unwrap().is_empty()); } + /// Seed dismissals through the same path the backend tasks use. + fn dismiss_all(kv: &DetKv, pairs: impl IntoIterator) { + for pair in pairs { + mark_untracked_in(kv, pair).expect("dismiss pair"); + } + } + #[test] fn untracked_pairs_round_trip_through_the_kv_store() { let kv = empty_kv(); let pairs = BTreeSet::from([pair(1, 10), pair(2, 20)]); - write_untracked(&kv, &pairs).unwrap(); + dismiss_all(&kv, pairs.iter().copied()); assert_eq!(read_untracked(&kv).unwrap(), pairs); } - /// The stored layout is `(token_id, identity_id)` and is fixed by - /// already-written user data: reading it back as the wrong field would - /// silently un-track a pair nobody dismissed. #[test] - fn untracked_pairs_persist_in_token_then_identity_order() { + fn dismissing_the_same_pair_twice_is_idempotent() { let kv = empty_kv(); - write_untracked(&kv, &BTreeSet::from([pair(1, 10)])).unwrap(); + dismiss_all(&kv, [pair(1, 10), pair(1, 10)]); + assert_eq!(read_untracked(&kv).unwrap(), BTreeSet::from([pair(1, 10)])); + } - let payload: Vec<([u8; 32], [u8; 32])> = kv - .get(DetScope::Global, TOKEN_UNTRACKED_KEY) - .unwrap() - .unwrap(); + #[test] + fn re_tracking_a_pair_that_was_never_dismissed_is_a_noop() { + let kv = empty_kv(); + dismiss_all(&kv, [pair(1, 10)]); + clear_untracked_in(&kv, pair(2, 20)).unwrap(); + assert_eq!(read_untracked(&kv).unwrap(), BTreeSet::from([pair(1, 10)])); + } + + /// A marker key carries `:` in that order. Decoding it the + /// other way round would silently un-track a pair nobody dismissed. + #[test] + fn a_marker_key_round_trips_back_to_its_pair_token_id_first() { + let dismissed = pair(1, 10); + let key = untracked_key(&dismissed); assert_eq!( - payload, - vec![(ident(1).to_buffer(), ident(10).to_buffer())], - "token_id is stored first, identity_id second" + key, + format!( + "{TOKEN_UNTRACKED_PREFIX}{}:{}", + ident(1).to_string(Encoding::Base58), + ident(10).to_string(Encoding::Base58) + ), + "token_id is keyed first, identity_id second" ); + assert_eq!(parse_untracked_key(&key), Some(dismissed)); + } + + #[test] + fn an_unparseable_marker_key_is_skipped_rather_than_failing_the_read() { + let kv = empty_kv(); + dismiss_all(&kv, [pair(1, 10)]); + kv.put( + DetScope::Global, + &format!("{TOKEN_UNTRACKED_PREFIX}not-base58:nonsense"), + &(), + ) + .unwrap(); + + assert_eq!(read_untracked(&kv).unwrap(), BTreeSet::from([pair(1, 10)])); } #[test] @@ -969,7 +1035,7 @@ mod tests { let kv = empty_kv(); let cleared = ident(1); let kept = pair(2, 10); - write_untracked(&kv, &BTreeSet::from([pair(1, 10), pair(1, 11), kept])).unwrap(); + dismiss_all(&kv, [pair(1, 10), pair(1, 11), kept]); clear_untracked_token_in(&kv, &cleared).unwrap(); @@ -980,8 +1046,146 @@ mod tests { fn clearing_an_undismissed_token_is_a_noop() { let kv = empty_kv(); let pairs = BTreeSet::from([pair(1, 10)]); - write_untracked(&kv, &pairs).unwrap(); + dismiss_all(&kv, pairs.iter().copied()); clear_untracked_token_in(&kv, &ident(9)).unwrap(); assert_eq!(read_untracked(&kv).unwrap(), pairs); } + + // ---------------------------------------------------------------- + // Untracked pairs: concurrent dismissals must not clobber each other. + // + // Dismiss / re-track are independent backend tasks, each `tokio::spawn`ed, + // so two of them can overlap. A mutation that reads the whole dismissal + // set and writes it back has a window in which a concurrent mutation is + // read-before / written-over — the lost update surfaces as a dismissed + // token reappearing on the next refresh. + // ---------------------------------------------------------------- + + /// Delegates to [`InMemoryKv`], stalling *after* each read has taken its + /// snapshot. Two concurrent mutations therefore both observe the + /// pre-mutation state and write back late: a mutation that reads before it + /// writes loses its peer's update, while a mutation that only writes never + /// reads, never stalls, and cannot be clobbered. + #[derive(Default)] + struct StallingReadKv { + inner: InMemoryKv, + } + + impl KvStore for StallingReadKv { + fn get(&self, scope: &ObjectId, key: &str) -> Result>, KvError> { + let value = self.inner.get(scope, key); + std::thread::sleep(std::time::Duration::from_millis(200)); + value + } + + fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { + self.inner.put(scope, key, value) + } + + fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + self.inner.delete(scope, key) + } + + fn list_keys( + &self, + scope: &ObjectId, + prefix: Option<&str>, + ) -> Result, KvError> { + self.inner.list_keys(scope, prefix) + } + } + + fn stalling_kv() -> DetKv { + DetKv::from_store(Arc::new(StallingReadKv::default())) + } + + #[test] + fn concurrent_dismissals_of_different_pairs_both_survive() { + let kv = stalling_kv(); + let (first, second) = (pair(1, 10), pair(2, 20)); + + std::thread::scope(|scope| { + scope.spawn(|| mark_untracked_in(&kv, first).expect("dismiss first")); + scope.spawn(|| mark_untracked_in(&kv, second).expect("dismiss second")); + }); + + assert_eq!( + read_untracked(&kv).unwrap(), + BTreeSet::from([first, second]), + "each dismissal must be an independent write — neither may clobber the other" + ); + } + + #[test] + fn a_dismissal_racing_a_re_track_of_another_pair_keeps_both_mutations() { + let kv = stalling_kv(); + let (dismissed, kept, retracked) = (pair(1, 10), pair(2, 20), pair(3, 30)); + for seed in [retracked, kept] { + mark_untracked_in(&kv, seed).expect("seed dismissal"); + } + + std::thread::scope(|scope| { + scope.spawn(|| mark_untracked_in(&kv, dismissed).expect("dismiss")); + scope.spawn(|| clear_untracked_in(&kv, retracked).expect("re-track")); + }); + + assert_eq!( + read_untracked(&kv).unwrap(), + BTreeSet::from([dismissed, kept]), + "a dismissal and a re-track of different pairs must both land" + ); + } + + /// Records every store operation so a read-modify-write can be spotted + /// structurally, without relying on a thread interleaving. + #[derive(Default)] + struct RecordingKv { + inner: InMemoryKv, + ops: Mutex>, + } + + impl KvStore for RecordingKv { + fn get(&self, scope: &ObjectId, key: &str) -> Result>, KvError> { + self.ops.lock().unwrap().push(format!("get {key}")); + self.inner.get(scope, key) + } + + fn put(&self, scope: &ObjectId, key: &str, value: &[u8]) -> Result<(), KvError> { + self.ops.lock().unwrap().push(format!("put {key}")); + self.inner.put(scope, key, value) + } + + fn delete(&self, scope: &ObjectId, key: &str) -> Result<(), KvError> { + self.ops.lock().unwrap().push(format!("delete {key}")); + self.inner.delete(scope, key) + } + + fn list_keys( + &self, + scope: &ObjectId, + prefix: Option<&str>, + ) -> Result, KvError> { + self.inner.list_keys(scope, prefix) + } + } + + /// The structural invariant behind the two race tests above: a dismissal + /// and a re-track each touch exactly their own pair's key, and neither + /// reads the dismissal set first — so there is no window to lose. + #[test] + fn dismissing_and_re_tracking_a_pair_are_single_writes_with_no_read_back() { + let store = Arc::new(RecordingKv::default()); + let kv = DetKv::from_store(store.clone()); + let dismissed = pair(1, 10); + let key = untracked_key(&dismissed); + + mark_untracked_in(&kv, dismissed).unwrap(); + clear_untracked_in(&kv, dismissed).unwrap(); + + assert_eq!( + *store.ops.lock().unwrap(), + vec![format!("put {key}"), format!("delete {key}")], + "a mutation that reads the dismissal set before writing it back would race a concurrent one" + ); + } } From 27f5291d252b1aeac1b4510e4f12e5c0b4c710eb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 11:58:37 +0000 Subject: [PATCH 19/30] fix(dashpay): scope contact results to their identity, split resolution markers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/dashpay/contact_requests.rs | 232 +++++++--- src/backend_task/dashpay/contacts.rs | 14 +- src/backend_task/dashpay/errors.rs | 9 + src/backend_task/mod.rs | 11 +- src/ui/dashpay/contact_details.rs | 12 +- src/ui/dashpay/contact_requests.rs | 30 +- src/ui/dashpay/contacts_list.rs | 15 +- src/ui/identity/hub_screen.rs | 59 ++- src/wallet_backend/dashpay.rs | 419 ++++++++++++++----- tests/backend-e2e/dashpay_tasks.rs | 14 +- 10 files changed, 646 insertions(+), 169 deletions(-) diff --git a/src/backend_task/dashpay/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 1c6889903..ac15ef7c7 100644 --- a/src/backend_task/dashpay/contact_requests.rs +++ b/src/backend_task/dashpay/contact_requests.rs @@ -121,14 +121,18 @@ pub async fn load_contact_requests( }); // Drop requests the user has already resolved locally (declined an incoming - // one, or cancelled a sent one). Platform keeps contactRequest documents + // one, or withdrawn a sent one). Platform keeps contactRequest documents // forever, so without this the resolved row reappears on every reload. let backend = app_context.wallet_backend().ok(); retain_unresolved( &mut incoming, &mut outgoing, - |counterparty| match &backend { - Some(backend) => backend.dashpay_is_rejected(&identity_id, counterparty), + |sender| match &backend { + Some(backend) => backend.dashpay_is_declined(&identity_id, sender), + None => false, + }, + |recipient| match &backend { + Some(backend) => backend.dashpay_is_withdrawn(&identity_id, recipient), None => false, }, ); @@ -139,21 +143,33 @@ pub async fn load_contact_requests( outgoing.len() ); - Ok(BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing }) + Ok(BackendTaskSuccessResult::DashPayContactRequests { + identity: identity_id, + incoming, + outgoing, + }) } -/// Drop every request whose counterparty the user has already resolved, per -/// `is_resolved`. An outgoing document with an unreadable `toUserId` is kept: -/// we cannot prove it was resolved, and hiding a request the user may still be -/// waiting on is the worse failure. +/// Drop every request the user has already resolved: an incoming one they +/// declined (per `is_declined`, keyed on the sender) or a sent one they withdrew +/// (per `is_withdrawn`, keyed on the recipient). +/// +/// The two directions are asked separately on purpose — withdrawing our request +/// to Bob resolves nothing about the request Bob sends us afterwards, and a +/// shared marker would silently hide it. +/// +/// An outgoing document with an unreadable `toUserId` is kept: we cannot prove +/// it was resolved, and hiding a request the user may still be waiting on is the +/// worse failure. fn retain_unresolved( incoming: &mut Vec<(Identifier, Document)>, outgoing: &mut Vec<(Identifier, Document)>, - is_resolved: impl Fn(&Identifier) -> bool, + is_declined: impl Fn(&Identifier) -> bool, + is_withdrawn: impl Fn(&Identifier) -> bool, ) { - incoming.retain(|(_, doc)| !is_resolved(&doc.owner_id())); + incoming.retain(|(_, doc)| !is_declined(&doc.owner_id())); outgoing.retain(|(_, doc)| match contact_request_recipient(doc) { - Some(to) => !is_resolved(&to), + Some(to) => !is_withdrawn(&to), None => true, }); } @@ -173,6 +189,25 @@ fn recipient_of_sent_request( }) } +/// Verify that `doc` is a contact request addressed to `recipient`, and return +/// its sender. +/// +/// Accepting or declining reads the counterparty off the fetched document, so +/// without this check a stale row — one the user clicked after switching +/// identity — would sign a real state transition under the wrong identity's key. +fn sender_of_received_request( + doc: &Document, + recipient: &Identifier, +) -> Result { + match contact_request_recipient(doc) { + Some(to) if to == *recipient => Ok(doc.owner_id()), + Some(_) => Err(DashPayError::ContactRequestNotAddressedToYou), + None => Err(DashPayError::InvalidDocument { + reason: "contact request document is missing its toUserId field".to_string(), + }), + } +} + pub async fn send_contact_request( app_context: &Arc, sdk: &Sdk, @@ -571,14 +606,18 @@ pub async fn send_contact_request_with_proof( ); } - // Sending to someone retires an earlier decline or withdrawal of theirs: - // the user has deliberately re-engaged, so their requests must stop being - // filtered out of the list. - if let Err(err) = backend.dashpay_unmark_rejected(&owner_id, &to_identity_id) { + // Sending to someone retires an earlier decline of their request and an + // earlier withdrawal of ours: the user has deliberately re-engaged, so + // neither direction may stay filtered out of the list. + // Both markers are cleared unconditionally — a failure on one must not + // leave the other standing. + let declined = backend.dashpay_unmark_declined(&owner_id, &to_identity_id); + let withdrawn = backend.dashpay_unmark_withdrawn(&owner_id, &to_identity_id); + if let Err(err) = declined.and(withdrawn) { tracing::debug!( %err, - "Clearing the stale rejection marker failed; an earlier declined \ - request from this person may stay hidden until the next decline is cleared", + "Clearing the stale resolution markers failed; an earlier declined or \ + withdrawn request involving this person may stay hidden", ); } } @@ -666,8 +705,9 @@ pub async fn accept_contact_request( .await? .ok_or(TaskError::DocumentNotFound)?; - // Get the sender's identity (the owner of the incoming request) - let from_identity_id = doc.owner_id(); + // Verify the request was addressed to us before acting on it, and get the + // sender's identity (the owner of the incoming request). + let from_identity_id = sender_of_received_request(&doc, &identity.identity.id())?; // Check if we already sent a contact request to this identity let mut existing_query = contact_request_query(app_context)?; @@ -798,11 +838,13 @@ pub async fn reject_contact_request( .await? .ok_or(TaskError::DocumentNotFound)?; - let from_identity_id = doc.owner_id(); // Captured before `identity` is moved into `create_or_update_contact_info`; - // the rejection marker is scoped to this acting identity. + // the decline marker is scoped to this acting identity. let owner_id = identity.identity.id(); + // Verify the request was addressed to us before declining it. + let from_identity_id = sender_of_received_request(&doc, &owner_id)?; + // Create or update contactInfo to mark this contact as hidden use super::contact_info::create_or_update_contact_info; @@ -818,22 +860,23 @@ pub async fn reject_contact_request( ) .await?; - // Mirror the rejection into the DET-local sidecar so `DashpayView` - // surfaces the request as "rejected" until a fresh outgoing/incoming - // pair establishes a contact. DashPay has no on-chain "rejected" flag, - // so the sidecar is the source of truth here. + // Mirror the decline into the DET-local sidecar so `DashpayView` surfaces + // the request as "rejected" until a fresh outgoing/incoming pair + // establishes a contact. DashPay has no on-chain "rejected" flag, so the + // sidecar is the source of truth here. // // The reader keys on the counterparty's identity id under the acting // identity's own scope (see `DashpayView::contact_requests`), so we pass // both `owner_id` and the original sender identity, not the request - // document id. + // document id. The marker is incoming-only: it must not silence a request + // we later send to that same person. if let Ok(backend) = app_context.wallet_backend() - && let Err(e) = backend.dashpay_mark_rejected(&owner_id, &from_identity_id) + && let Err(e) = backend.dashpay_mark_declined(&owner_id, &from_identity_id) { tracing::debug!( from = %from_identity_id.to_string(Encoding::Base58), error = ?e, - "DashPay rejection sidecar write failed; request will still display as pending" + "DashPay decline sidecar write failed; request will still display as pending" ); } @@ -869,7 +912,13 @@ trait CancelOps { /// Record the withdrawal in the DET sidecar so the request stops being /// listed as pending. - fn mark_withdrawn(&self); + /// + /// # Errors + /// + /// The marker is the only thing that retires the row — Platform keeps the + /// `contactRequest` document forever — so a failed write must surface + /// rather than be reported as a completed cancellation. + fn mark_withdrawn(&self) -> Result<(), TaskError>; } /// Check, hide, then re-check and undo the hide if the recipient answered inside @@ -900,7 +949,7 @@ async fn cancel_flow(ops: &O) -> Result return Ok(CancelOutcome::AlreadyEstablished); } - ops.mark_withdrawn(); + ops.mark_withdrawn()?; Ok(CancelOutcome::Withdrawn) } @@ -949,16 +998,10 @@ impl CancelOps for PlatformCancelOps<'_> { .map(|_| ()) } - fn mark_withdrawn(&self) { - if let Ok(backend) = self.app_context.wallet_backend() - && let Err(e) = backend.dashpay_mark_rejected(&self.owner_id, &self.to_identity_id) - { - tracing::debug!( - to = %self.to_identity_id.to_string(Encoding::Base58), - error = ?e, - "DashPay cancellation sidecar write failed; request will still display as pending" - ); - } + fn mark_withdrawn(&self) -> Result<(), TaskError> { + self.app_context + .wallet_backend()? + .dashpay_mark_withdrawn(&self.owner_id, &self.to_identity_id) } } @@ -1075,27 +1118,57 @@ mod tests { } #[test] - fn retain_unresolved_drops_declined_and_cancelled_rows() { + fn accepting_a_request_addressed_to_someone_else_is_rejected() { + // A stale row from a previous identity must never reach a signed + // acceptance under the identity now in use. + let doc = request_doc(id(1), Some(id(2))); + assert!(matches!( + sender_of_received_request(&doc, &id(3)), + Err(DashPayError::ContactRequestNotAddressedToYou) + )); + } + + #[test] + fn accepting_a_request_addressed_to_us_returns_its_sender() { + let doc = request_doc(id(1), Some(id(2))); + assert_eq!(sender_of_received_request(&doc, &id(2)).unwrap(), id(1)); + } + + #[test] + fn accepting_a_malformed_request_is_rejected() { + let doc = request_doc(id(1), None); + assert!(matches!( + sender_of_received_request(&doc, &id(2)), + Err(DashPayError::InvalidDocument { .. }) + )); + } + + #[test] + fn retain_unresolved_drops_declined_and_withdrawn_rows() { // Incoming from id(1) — declined. Incoming from id(2) — still pending. let mut incoming = vec![ (id(10), request_doc(id(1), Some(id(9)))), (id(11), request_doc(id(2), Some(id(9)))), ]; - // Outgoing to id(3) — cancelled. Outgoing to id(4) — still pending. + // Outgoing to id(3) — withdrawn. Outgoing to id(4) — still pending. let mut outgoing = vec![ (id(12), request_doc(id(9), Some(id(3)))), (id(13), request_doc(id(9), Some(id(4)))), ]; - let resolved = [id(1), id(3)]; - retain_unresolved(&mut incoming, &mut outgoing, |c| resolved.contains(c)); + retain_unresolved( + &mut incoming, + &mut outgoing, + |sender| *sender == id(1), + |recipient| *recipient == id(3), + ); assert_eq!(incoming.len(), 1, "the declined request must not be listed"); assert_eq!(incoming[0].0, id(11)); assert_eq!( outgoing.len(), 1, - "the cancelled request must not be listed" + "the withdrawn request must not be listed" ); assert_eq!(outgoing[0].0, id(13)); } @@ -1105,7 +1178,7 @@ mod tests { let mut incoming = Vec::new(); let mut outgoing = vec![(id(12), request_doc(id(9), None))]; - retain_unresolved(&mut incoming, &mut outgoing, |_| true); + retain_unresolved(&mut incoming, &mut outgoing, |_| true, |_| true); assert_eq!( outgoing.len(), @@ -1114,6 +1187,49 @@ mod tests { ); } + #[test] + fn a_withdrawn_outgoing_request_does_not_hide_a_later_incoming_one() { + // We (id 9) withdrew a request to id(2), so id(2) carries a withdrawal + // marker. Later id(2) sends us a genuine request: it is new, unresolved + // business and must be listed. + let mut incoming = vec![(id(20), request_doc(id(2), Some(id(9))))]; + let mut outgoing = Vec::new(); + + retain_unresolved( + &mut incoming, + &mut outgoing, + /* is_declined = */ |_| false, + /* is_withdrawn = */ |recipient| *recipient == id(2), + ); + + assert_eq!( + incoming.len(), + 1, + "withdrawing our own request must not silence the other side's new request" + ); + } + + #[test] + fn a_declined_incoming_request_does_not_hide_a_later_outgoing_one() { + // The mirror case: we declined id(2)'s request, then changed our mind + // and sent them one. Ours is pending until they answer it. + let mut incoming = Vec::new(); + let mut outgoing = vec![(id(21), request_doc(id(9), Some(id(2))))]; + + retain_unresolved( + &mut incoming, + &mut outgoing, + /* is_declined = */ |sender| *sender == id(2), + /* is_withdrawn = */ |_| false, + ); + + assert_eq!( + outgoing.len(), + 1, + "declining their earlier request must not silence the request we sent them" + ); + } + // ----------------------------------------------------------------- // Cancellation flow — the reciprocal check and the hide broadcast are // separate Platform round-trips, so the window between them is where a @@ -1135,6 +1251,8 @@ mod tests { withdrawn: Mutex, /// When set, the first hide broadcast fails. hide_fails: bool, + /// When set, recording the withdrawal in the sidecar fails. + withdraw_fails: bool, } impl ScriptedOps { @@ -1175,8 +1293,12 @@ mod tests { Ok(()) } - fn mark_withdrawn(&self) { + fn mark_withdrawn(&self) -> Result<(), TaskError> { + if self.withdraw_fails { + return Err(TaskError::WalletBackendNotYetWired); + } *self.withdrawn.lock().expect("not poisoned") = true; + Ok(()) } } @@ -1254,4 +1376,20 @@ mod tests { "the request is still pending on Platform, so it must keep being listed" ); } + + #[tokio::test] + async fn a_failed_withdrawal_record_is_not_reported_as_a_cancellation() { + // The marker is what retires the row from the listing. Without it the + // request comes back as pending on the next reload, so announcing a + // successful cancellation would be a lie. + let ops = ScriptedOps { + withdraw_fails: true, + ..ScriptedOps::with_reciprocal([false, false]) + }; + + assert!( + cancel_flow(&ops).await.is_err(), + "a withdrawal the sidecar refused must surface as an error, not as success" + ); + } } diff --git a/src/backend_task/dashpay/contacts.rs b/src/backend_task/dashpay/contacts.rs index f6e8e8f71..08ac9cd63 100644 --- a/src/backend_task/dashpay/contacts.rs +++ b/src/backend_task/dashpay/contacts.rs @@ -444,9 +444,10 @@ pub async fn load_contacts( // effort: a cache write miss only costs the offline optimisation. cache_contact_profiles(app_context, &contact_list); - Ok(BackendTaskSuccessResult::DashPayContactsWithInfo( - contact_list, - )) + Ok(BackendTaskSuccessResult::DashPayContactsWithInfo { + identity: identity_id, + contacts: contact_list, + }) } /// Read the contact list for `identity` entirely from offline state: contact @@ -511,9 +512,10 @@ pub async fn load_contacts_offline( }); } - Ok(BackendTaskSuccessResult::DashPayContactsWithInfo( - contact_list, - )) + Ok(BackendTaskSuccessResult::DashPayContactsWithInfo { + identity: owner_id, + contacts: contact_list, + }) } /// Write each contact's fetched display profile into the DET contact-profile diff --git a/src/backend_task/dashpay/errors.rs b/src/backend_task/dashpay/errors.rs index b78f203bc..178c823b4 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -105,6 +105,15 @@ pub enum DashPayError { )] ContactRequestNotSentByYou, + /// The request selected for acceptance or decline was addressed to a + /// different identity. Almost always a stale list: the user switched + /// identity while the Contacts tab still showed the previous identity's + /// received requests. + #[error( + "This request was not sent to the identity you are using. Refresh your contacts and try again." + )] + ContactRequestNotAddressedToYou, + /// Encrypted contact info fields exceed DashPay contract limits. #[error("Contact info is too large to save. Try shortening your nickname or note.")] ContactInfoValidationFailed { errors: Vec }, diff --git a/src/backend_task/mod.rs b/src/backend_task/mod.rs index c21e020f3..e09eabbee 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -223,11 +223,20 @@ pub enum BackendTaskSuccessResult { DashPayContactProfile(Option), // Contact's public profile document DashPayProfileSearchResults(Vec<(Identifier, Option, String)>), // Search results: (identity_id, profile_document, username) DashPayContactRequests { + /// The identity the requests were loaded for. An identity switch cannot + /// cancel an in-flight load, so the consumer must drop a result whose + /// identity is no longer the selected one. + identity: Identifier, incoming: Vec<(Identifier, Document)>, // (request_id, document) outgoing: Vec<(Identifier, Document)>, // (request_id, document) }, DashPayContacts(Vec), // List of contact identity IDs - DashPayContactsWithInfo(Vec), // List of contacts with metadata + DashPayContactsWithInfo { + /// The identity the contacts were loaded for — see + /// [`BackendTaskSuccessResult::DashPayContactRequests`]. + identity: Identifier, + contacts: Vec, + }, DashPayPaymentHistory(Vec<(String, String, u64, bool, String)>), // (tx_id, contact_name, amount, is_incoming, memo) DashPayProfileUpdated(Identifier), // Identity ID of updated profile DashPayContactRequestSent(String), // Username or ID of recipient diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index cbaf2d31a..7ae05b674 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -622,7 +622,17 @@ impl ScreenLike for ContactDetailsScreen { ); } } - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { + BackendTaskSuccessResult::DashPayContactsWithInfo { + identity, + contacts: contacts_data, + } => { + // Contacts loaded for another identity say nothing about this + // screen's contact — a reload that outlived an identity switch + // must not overwrite what is on screen. + if identity != self.identity.identity.id() { + return; + } + // If a full contacts reload happened, update our contact if present for contact_data in contacts_data { if contact_data.identity_id == self.contact_id { diff --git a/src/ui/dashpay/contact_requests.rs b/src/ui/dashpay/contact_requests.rs index f1417f99c..36bfdd4a3 100644 --- a/src/ui/dashpay/contact_requests.rs +++ b/src/ui/dashpay/contact_requests.rs @@ -882,20 +882,17 @@ impl ScreenLike for ContactRequests { self.loading = false; match result { - BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing } => { + BackendTaskSuccessResult::DashPayContactRequests { + identity, + incoming, + outgoing, + } => { tracing::debug!( "Received DashPayContactRequests result: {} incoming, {} outgoing", incoming.len(), outgoing.len() ); - // Clear existing requests - self.incoming_requests.clear(); - self.outgoing_requests.clear(); - - // Mark as fetched - self.has_fetched_requests = true; - // Get current identity for saving to database let Some(selected_identity) = self.selected_identity.as_ref() else { tracing::warn!( @@ -905,6 +902,23 @@ impl ScreenLike for ContactRequests { }; let current_identity_id = selected_identity.identity.id(); + // An identity switch cannot cancel a load already in flight, so + // a result for the identity we left must not repopulate the + // lists under the identity we are on. + if identity != current_identity_id { + tracing::debug!( + "Discarding contact requests for a no-longer-selected identity" + ); + return; + } + + // Clear existing requests + self.incoming_requests.clear(); + self.outgoing_requests.clear(); + + // Mark as fetched + self.has_fetched_requests = true; + // Process incoming requests for (id, doc) in incoming.iter() { let properties = doc.properties(); diff --git a/src/ui/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index 2f57db78f..4f20fa198 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -917,13 +917,24 @@ impl ScreenLike for ContactsList { self.has_loaded = true; self.message = None; } - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts_data) => { + BackendTaskSuccessResult::DashPayContactsWithInfo { + identity, + contacts: contacts_data, + } => { + let owner_id_opt = self.selected_identity.as_ref().map(|i| i.identity.id()); + + // A load that outlived an identity switch belongs to the + // identity we left — it must not repopulate this list. + if owner_id_opt != Some(identity) { + tracing::debug!("Discarding contacts for a no-longer-selected identity"); + return; + } + // Clear existing contacts and repopulate the in-memory map // from the adapter result. Upstream `ManagedIdentity` is // now the authoritative source for contact rows (D4d), so // the DET-local cache writes are gone. self.contacts.clear(); - let owner_id_opt = self.selected_identity.as_ref().map(|i| i.identity.id()); for contact_data in contacts_data { // Skip self-contacts (where contact is the same as the owner) if owner_id_opt diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 655ec5c3d..33901f56c 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -125,6 +125,12 @@ impl IdentityHubScreen { self.selected_tab = tab; } + /// Whether an identity-scoped backend result still belongs to the identity + /// on screen. See [`applies_to_selected_identity`]. + fn result_is_for_selected_identity(&self, result_identity: &Identifier) -> bool { + applies_to_selected_identity(self.app_context.selected_identity_id(), result_identity) + } + /// Retire a request row the backend just resolved (accepted, declined, or /// cancelled), confirm it to the user, and re-arm the Contacts load so the /// authoritative lists replace the local edit. @@ -358,14 +364,22 @@ impl ScreenLike for IdentityHubScreen { // can render real RequestCard rows instead of hardcoded empties. // The result arrives from LoadContactRequests, // dispatched alongside LoadContacts in contacts::render_populated. - BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing } => { - self.contacts_state - .record_requests(incoming.clone(), outgoing.clone()); + BackendTaskSuccessResult::DashPayContactRequests { + identity, + incoming, + outgoing, + } => { + if self.result_is_for_selected_identity(identity) { + self.contacts_state + .record_requests(incoming.clone(), outgoing.clone()); + } } // The established-contact list behind the Contacts tab's active // section and its search box. - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts) => { - self.contacts_state.record_contacts(contacts.clone()); + BackendTaskSuccessResult::DashPayContactsWithInfo { identity, contacts } => { + if self.result_is_for_selected_identity(identity) { + self.contacts_state.record_contacts(contacts.clone()); + } } // A resolved request: drop the row now so the list reflects the // action immediately, then re-arm the load so the authoritative @@ -419,6 +433,20 @@ impl ScreenLike for IdentityHubScreen { } } +/// Whether a backend result loaded for `result_identity` may still be applied +/// while `selected` is the identity on screen. +/// +/// Switching identity cannot cancel a load already in flight, so a result for +/// the previous identity can land after the switch. Applying it would paint one +/// identity's contacts and requests under another's name — and hand the user +/// rows that act under the wrong key. +fn applies_to_selected_identity( + selected: Option, + result_identity: &Identifier, +) -> bool { + selected.as_ref() == Some(result_identity) +} + #[cfg(test)] mod tests { use super::*; @@ -464,4 +492,25 @@ mod tests { // Next good load = empty account — falls back to Onboarding legitimately. assert_eq!(last_good, HubLanding::Onboarding); } + + fn id(byte: u8) -> Identifier { + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") + } + + #[test] + fn a_result_for_the_selected_identity_applies() { + assert!(applies_to_selected_identity(Some(id(1)), &id(1))); + } + + #[test] + fn a_result_for_a_previously_selected_identity_is_discarded() { + // The load was dispatched for identity A; the user switched to B before + // it returned. A's contacts must never appear under B. + assert!(!applies_to_selected_identity(Some(id(2)), &id(1))); + } + + #[test] + fn a_result_arriving_with_no_identity_selected_is_discarded() { + assert!(!applies_to_selected_identity(None, &id(1))); + } } diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 3828ba401..ecb8ef8c3 100644 --- a/src/wallet_backend/dashpay.rs +++ b/src/wallet_backend/dashpay.rs @@ -208,8 +208,8 @@ pub(crate) fn derive_contact_info_encryption_keys( // // Two sidecar families (`timestamps`, `addr_map`) use `DetScope::Global` // against the per-network upstream persister. The network already partitions -// the database file, so no `:` prefix is needed inside the key. Four -// families (`blocked`, `rejected`, `private`, `address_index`) use +// the database file, so no `:` prefix is needed inside the key. Five +// families (`blocked`, `declined`, `withdrawn`, `private`, `address_index`) use // `DetScope::Identity(&owner)` — the owner is carried by the scope, so the // key contains only the counterparty id; the upstream soft-cascade reaps them // when the owner identity row is deleted. @@ -219,11 +219,19 @@ pub(crate) fn derive_contact_info_encryption_keys( /// own decision, so the marker must not bleed across identities that share a /// wallet. Key shape: `det:dashpay:blocked:`. const KV_PREFIX_BLOCKED: &str = "det:dashpay:blocked:"; -/// Mark a contact request as rejected. Value: empty (`()`). Presence is the -/// signal. Scope: [`DetScope::Identity(&owner)`] — rejection is the acting -/// identity's own decision and must not bleed across identities. Key shape: -/// `det:dashpay:rejected:`. -const KV_PREFIX_REJECTED: &str = "det:dashpay:rejected:"; +/// Mark an *incoming* contact request as declined. Value: empty (`()`). +/// Presence is the signal. Scope: [`DetScope::Identity(&owner)`] — declining is +/// the acting identity's own decision and must not bleed across identities. +/// Key shape: `det:dashpay:declined:`. +const KV_PREFIX_DECLINED: &str = "det:dashpay:declined:"; +/// Mark an *outgoing* contact request as withdrawn. Value: empty (`()`). +/// Presence is the signal. Scope: [`DetScope::Identity(&owner)`]. +/// Key shape: `det:dashpay:withdrawn:`. +/// +/// Kept apart from [`KV_PREFIX_DECLINED`] because the two resolutions run in +/// opposite directions: withdrawing our request to Bob says nothing about a +/// request Bob later sends us, and a single marker would silently hide it. +const KV_PREFIX_WITHDRAWN: &str = "det:dashpay:withdrawn:"; /// DET-local `(created_at, updated_at)` timestamps for an entity (contact, request). /// Value: `(i64, i64)` encoded by the [`DetKv`] schema. Scope: [`DetScope::Global`]. const KV_PREFIX_TIMESTAMPS: &str = "det:dashpay:timestamps:"; @@ -352,7 +360,8 @@ impl<'a> DashpayView<'a> { for (recipient_id, request) in dashpay.sent_contact_requests().iter() { let status = derive_request_status( owner, - /* request_id_for_sidecar = */ recipient_id, + /* counterparty = */ recipient_id, + ContactRequestDirection::Sent, /* has_matching_established = */ dashpay.established_contacts().contains_key(recipient_id), request.created_at, @@ -373,6 +382,7 @@ impl<'a> DashpayView<'a> { let status = derive_request_status( owner, sender_id, + ContactRequestDirection::Received, dashpay.established_contacts().contains_key(sender_id), request.created_at, now_ms, @@ -581,12 +591,15 @@ fn profile_to_det( /// /// Precedence: `accepted` > `rejected` > `expired` > `pending`. A /// pending request older than [`DASHPAY_REQUEST_EXPIRY_DAYS`] (per -/// `created_at_ms` vs `now_ms`) reports as `"expired"`. The `rejected` -/// marker is read under `owner`'s Identity scope so one identity's rejection -/// never colours another identity's view of the same counterparty. +/// `created_at_ms` vs `now_ms`) reports as `"expired"`. The resolution marker +/// is read per `direction` — a sent request answers to the withdrawal marker, +/// a received one to the decline marker — under `owner`'s Identity scope, so +/// neither another identity's decision nor the opposite direction's colours +/// this request's status. fn derive_request_status( owner: &Identifier, counterparty: &Identifier, + direction: ContactRequestDirection, has_matching_established: bool, created_at_ms: u64, now_ms: u64, @@ -595,7 +608,7 @@ fn derive_request_status( if has_matching_established { return ContactRequestStatus::Accepted; } - if kv_contains(kv, owner, KV_PREFIX_REJECTED, counterparty) { + if kv_contains(kv, owner, resolution_prefix(direction), counterparty) { return ContactRequestStatus::Rejected; } let age_ms = now_ms.saturating_sub(created_at_ms); @@ -605,6 +618,15 @@ fn derive_request_status( ContactRequestStatus::Pending } +/// The sidecar marker that retires a request running in `direction`: a sent +/// request is retired by withdrawing it, a received one by declining it. +fn resolution_prefix(direction: ContactRequestDirection) -> &'static str { + match direction { + ContactRequestDirection::Sent => KV_PREFIX_WITHDRAWN, + ContactRequestDirection::Received => KV_PREFIX_DECLINED, + } +} + /// The [`DASHPAY_REQUEST_EXPIRY_DAYS`] window expressed in milliseconds, the /// unit upstream `created_at` timestamps use. fn request_expiry_threshold_ms() -> u64 { @@ -850,49 +872,105 @@ impl WalletBackend { .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) } - /// Record that `owner` has rejected an incoming contact request from - /// `counterparty_id` (or, equivalently, withdrew the sent request from - /// their point of view). Scoped to `owner`'s Identity so the rejection is + /// Record that `owner` declined the *incoming* contact request from + /// `counterparty_id`. Scoped to `owner`'s Identity so the decision is /// private to that identity. The sidecar key matches what [`DashpayView`] - /// consults when deriving request status. - pub fn dashpay_mark_rejected( + /// consults when deriving the status of a received request. + /// + /// Declining says nothing about a request `owner` may send *to* + /// `counterparty_id` — that direction has its own marker, see + /// [`dashpay_mark_withdrawn`](Self::dashpay_mark_withdrawn). + pub fn dashpay_mark_declined( &self, owner: &Identifier, counterparty_id: &Identifier, ) -> Result<(), TaskError> { - let owner_buf = owner.to_buffer(); - let key = sidecar_key(KV_PREFIX_REJECTED, counterparty_id); - self.kv() - .put::<()>(DetScope::Identity(&owner_buf), &key, &()) - .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + self.put_marker(owner, KV_PREFIX_DECLINED, counterparty_id) } - /// Whether `owner` has resolved the contact request from `counterparty_id` - /// locally — declined an incoming one, or withdrawn a sent one. Reads the - /// same owner-scoped marker [`dashpay_mark_rejected`](Self::dashpay_mark_rejected) - /// writes and [`DashpayView`] derives its status from. + /// Whether `owner` declined the incoming contact request from + /// `counterparty_id`. Reads the same owner-scoped marker + /// [`dashpay_mark_declined`](Self::dashpay_mark_declined) writes. /// /// A `contactRequest` document is immutable and undeletable on Platform, so - /// this marker is the only thing that can retire a resolved request from a + /// this marker is the only thing that can retire a declined request from a /// listing. - pub fn dashpay_is_rejected(&self, owner: &Identifier, counterparty_id: &Identifier) -> bool { - kv_contains(&self.kv(), owner, KV_PREFIX_REJECTED, counterparty_id) + pub fn dashpay_is_declined(&self, owner: &Identifier, counterparty_id: &Identifier) -> bool { + kv_contains(&self.kv(), owner, KV_PREFIX_DECLINED, counterparty_id) } - /// Clear `owner`'s rejection marker for `counterparty_id`. Idempotent — + /// Clear `owner`'s decline marker for `counterparty_id`. Idempotent — /// clearing an absent marker is `Ok(())`. /// /// Sending a contact request to someone is an explicit re-engagement, so it - /// retires an earlier decline or withdrawal. Without this, a request the - /// user once declined would stay filtered out of their list forever, even - /// after they deliberately added that person again. - pub fn dashpay_unmark_rejected( + /// retires an earlier decline. Without this, a request the user once + /// declined would stay filtered out of their list forever, even after they + /// deliberately added that person again. + pub fn dashpay_unmark_declined( &self, owner: &Identifier, counterparty_id: &Identifier, + ) -> Result<(), TaskError> { + self.delete_marker(owner, KV_PREFIX_DECLINED, counterparty_id) + } + + /// Record that `owner` withdrew the *outgoing* contact request they sent to + /// `counterparty_id`. Scoped to `owner`'s Identity. The sidecar key matches + /// what [`DashpayView`] consults when deriving the status of a sent request. + /// + /// Withdrawing says nothing about a request `counterparty_id` may later send + /// to `owner` — that direction has its own marker, see + /// [`dashpay_mark_declined`](Self::dashpay_mark_declined). + pub fn dashpay_mark_withdrawn( + &self, + owner: &Identifier, + counterparty_id: &Identifier, + ) -> Result<(), TaskError> { + self.put_marker(owner, KV_PREFIX_WITHDRAWN, counterparty_id) + } + + /// Whether `owner` withdrew the contact request they sent to + /// `counterparty_id`. Reads the same owner-scoped marker + /// [`dashpay_mark_withdrawn`](Self::dashpay_mark_withdrawn) writes. + pub fn dashpay_is_withdrawn(&self, owner: &Identifier, counterparty_id: &Identifier) -> bool { + kv_contains(&self.kv(), owner, KV_PREFIX_WITHDRAWN, counterparty_id) + } + + /// Clear `owner`'s withdrawal marker for `counterparty_id`. Idempotent — + /// clearing an absent marker is `Ok(())`. Re-sending a request to the same + /// person retires the earlier withdrawal, so the fresh request lists. + pub fn dashpay_unmark_withdrawn( + &self, + owner: &Identifier, + counterparty_id: &Identifier, + ) -> Result<(), TaskError> { + self.delete_marker(owner, KV_PREFIX_WITHDRAWN, counterparty_id) + } + + /// Write a presence-only marker keyed on `counterparty_id` under `owner`'s + /// Identity scope. + fn put_marker( + &self, + owner: &Identifier, + prefix: &str, + counterparty_id: &Identifier, + ) -> Result<(), TaskError> { + let owner_buf = owner.to_buffer(); + let key = sidecar_key(prefix, counterparty_id); + self.kv() + .put::<()>(DetScope::Identity(&owner_buf), &key, &()) + .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) + } + + /// Clear a presence-only marker written by [`put_marker`](Self::put_marker). + fn delete_marker( + &self, + owner: &Identifier, + prefix: &str, + counterparty_id: &Identifier, ) -> Result<(), TaskError> { let owner_buf = owner.to_buffer(); - let key = sidecar_key(KV_PREFIX_REJECTED, counterparty_id); + let key = sidecar_key(prefix, counterparty_id); self.kv() .delete(DetScope::Identity(&owner_buf), &key) .map_err(|e| TaskError::DashpaySidecarStorage { source: e }) @@ -1059,7 +1137,7 @@ impl WalletBackend { /// Drop every Identity-scoped DashPay overlay for `owner` — the /// per-contact private memos, address-index cursors, and the blocked / - /// rejected markers. + /// declined / withdrawn markers. /// /// The remaining Global-scoped overlays (timestamps, reverse address map) /// are not owner-scoped and are swept by the `det:dashpay:` Global prefix in @@ -1074,7 +1152,8 @@ impl WalletBackend { KV_PREFIX_PRIVATE, KV_PREFIX_ADDRESS_INDEX, KV_PREFIX_BLOCKED, - KV_PREFIX_REJECTED, + KV_PREFIX_DECLINED, + KV_PREFIX_WITHDRAWN, ] { let keys = kv .list(scope, Some(prefix)) @@ -1305,37 +1384,109 @@ mod tests { let now_ms: u64 = 1_000_000_000_000; let created_at_ms: u64 = now_ms - 60_000; assert_eq!( - derive_request_status(&owner, &counterparty, true, created_at_ms, now_ms, &kv), + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + true, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Accepted, "matching established contact wins" ); assert_eq!( - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Pending, - "no established + no rejection sidecar + fresh = pending" + "no established + no resolution sidecar + fresh = pending" ); } #[test] - fn rejected_request_status_reads_sidecar_when_present() { + fn declined_incoming_request_status_reads_the_decline_marker() { let kv = empty_kv(); let owner = id_from_byte(1); let counterparty = id_from_byte(2); let owner_buf = owner.to_buffer(); kv.put::<()>( DetScope::Identity(&owner_buf), - &sidecar_key(KV_PREFIX_REJECTED, &counterparty), + &sidecar_key(KV_PREFIX_DECLINED, &counterparty), &(), ) .unwrap(); let now_ms: u64 = 1_000_000_000_000; let created_at_ms: u64 = now_ms - 60_000; assert_eq!( - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Rejected ); } + /// A withdrawal and a decline resolve opposite directions. Neither may + /// colour the other, or cancelling our request to Bob would silently retire + /// the genuine request Bob sends us next. + #[test] + fn resolution_markers_do_not_leak_across_directions() { + let kv = empty_kv(); + let owner = id_from_byte(1); + let counterparty = id_from_byte(2); + let owner_buf = owner.to_buffer(); + let now_ms: u64 = 1_000_000_000_000; + let created_at_ms: u64 = now_ms - 60_000; + + // The owner withdrew the request they had sent to the counterparty. + kv.put::<()>( + DetScope::Identity(&owner_buf), + &sidecar_key(KV_PREFIX_WITHDRAWN, &counterparty), + &(), + ) + .unwrap(); + + assert_eq!( + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Sent, + false, + created_at_ms, + now_ms, + &kv + ), + ContactRequestStatus::Rejected, + "the withdrawn request we sent must stop showing as pending" + ); + assert_eq!( + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), + ContactRequestStatus::Pending, + "a request that person sends us afterwards is new business — it stays pending" + ); + } + #[test] fn expired_request_status_when_older_than_threshold() { let kv = empty_kv(); @@ -1346,7 +1497,15 @@ mod tests { let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; let created_at_ms: u64 = now_ms - threshold_ms - 60_000; assert_eq!( - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Expired, "older-than-threshold pending request reports as expired" ); @@ -1362,7 +1521,15 @@ mod tests { // One minute younger than the threshold. let created_at_ms: u64 = now_ms - threshold_ms + 60_000; assert_eq!( - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Pending ); } @@ -1448,15 +1615,15 @@ mod tests { } /// D3 contract: the key encoding used by the write helpers - /// (`dashpay_mark_blocked`, `dashpay_mark_rejected`, - /// `dashpay_set_timestamps`, `dashpay_set_payment_timestamps`) must - /// match the encoding the read helpers (`kv_contains`, - /// `kv_timestamps`, `kv_payment_timestamps`) consult — otherwise - /// every write is invisible to the view. + /// (`dashpay_mark_blocked`, `dashpay_mark_declined`, + /// `dashpay_mark_withdrawn`, `dashpay_set_timestamps`, + /// `dashpay_set_payment_timestamps`) must match the encoding the read + /// helpers (`kv_contains`, `kv_timestamps`, `kv_payment_timestamps`) + /// consult — otherwise every write is invisible to the view. /// - /// The blocked / rejected markers are owner-scoped (Wave 2 / F40): the - /// write lands under the owner's `DetScope::Identity` keyed only on the - /// counterparty, and `kv_contains` reads from the same place. + /// The blocked / declined / withdrawn markers are owner-scoped (Wave 2 / + /// F40): the write lands under the owner's `DetScope::Identity` keyed only + /// on the counterparty, and `kv_contains` reads from the same place. #[test] fn d3_blocked_marker_round_trips_through_sidecar_key() { let kv = empty_kv(); @@ -1476,27 +1643,41 @@ mod tests { } #[test] - fn d3_rejected_marker_round_trips_through_sidecar_key() { + fn d3_resolution_markers_round_trip_through_sidecar_keys() { let kv = empty_kv(); let owner = id_from_byte(1); let owner_buf = owner.to_buffer(); let counterparty = id_from_byte(8); - // What `dashpay_mark_rejected` writes: - kv.put::<()>( - DetScope::Identity(&owner_buf), - &sidecar_key(KV_PREFIX_REJECTED, &counterparty), - &(), - ) - .unwrap(); - // What `derive_request_status` reads: - assert!(kv_contains(&kv, &owner, KV_PREFIX_REJECTED, &counterparty)); - let now_ms: u64 = 1_000_000_000_000; let created_at_ms: u64 = now_ms - 60_000; - assert_eq!( - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), - ContactRequestStatus::Rejected - ); + + for (prefix, direction) in [ + (KV_PREFIX_DECLINED, ContactRequestDirection::Received), + (KV_PREFIX_WITHDRAWN, ContactRequestDirection::Sent), + ] { + let key = sidecar_key(prefix, &counterparty); + // What `dashpay_mark_declined` / `dashpay_mark_withdrawn` write: + kv.put::<()>(DetScope::Identity(&owner_buf), &key, &()) + .unwrap(); + // What `derive_request_status` reads: + assert!(kv_contains(&kv, &owner, prefix, &counterparty)); + assert_eq!( + derive_request_status( + &owner, + &counterparty, + direction, + false, + created_at_ms, + now_ms, + &kv + ), + ContactRequestStatus::Rejected + ); + + // And the matching unmark (delete) clears it. + kv.delete(DetScope::Identity(&owner_buf), &key).unwrap(); + assert!(!kv_contains(&kv, &owner, prefix, &counterparty)); + } } #[test] @@ -1567,10 +1748,9 @@ mod tests { } #[test] - fn d3_reject_then_list_contact_requests_yields_rejected_status() { - // Simulates: send → reject → list. After D3 wires - // `dashpay_mark_rejected`, the outgoing request's status flips - // to "rejected" without touching upstream presence (rejected + fn d3_withdraw_then_list_contact_requests_yields_rejected_status() { + // Simulates: send → withdraw → list. The outgoing request's status + // flips to "rejected" without touching upstream presence (withdrawn // requests are not removed from `sent_contact_requests`). let kv = empty_kv(); let owner = id_from_byte(1); @@ -1578,23 +1758,37 @@ mod tests { let counterparty = id_from_byte(2); kv.put::<()>( DetScope::Identity(&owner_buf), - &sidecar_key(KV_PREFIX_REJECTED, &counterparty), + &sidecar_key(KV_PREFIX_WITHDRAWN, &counterparty), &(), ) .unwrap(); let now_ms: u64 = 2_000_000_000_000; let created_at_ms: u64 = now_ms - 1_000; - let derived = - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv); + let derived = derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Sent, + false, + created_at_ms, + now_ms, + &kv, + ); assert_eq!(derived, ContactRequestStatus::Rejected); - // And the threshold-expiry override does not fire for rejected + // And the threshold-expiry override does not fire for resolved // requests — `rejected` precedence is higher than `expired`. let threshold_ms = (DASHPAY_REQUEST_EXPIRY_DAYS as u64) * 86_400_000; let old_created = now_ms - threshold_ms - 60_000; - let derived_old = - derive_request_status(&owner, &counterparty, false, old_created, now_ms, &kv); + let derived_old = derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Sent, + false, + old_created, + now_ms, + &kv, + ); assert_eq!(derived_old, ContactRequestStatus::Rejected); } @@ -1611,13 +1805,21 @@ mod tests { // 7 days + a margin of safety. let created_at_ms: u64 = now_ms - threshold_ms - 86_400_000; assert_eq!( - derive_request_status(&owner, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Expired ); } /// F40: two identities that share a wallet must not see each other's - /// blocked / rejected markers. Identity A blocks/rejects a counterparty; + /// blocked / declined markers. Identity A blocks/declines a counterparty; /// identity B's view of that same counterparty stays clean. #[test] fn markers_are_isolated_per_owner_identity() { @@ -1627,7 +1829,7 @@ mod tests { let counterparty = id_from_byte(3); let a_buf = owner_a.to_buffer(); - // Owner A blocks and rejects the counterparty. + // Owner A blocks the counterparty and declines their request. kv.put::<()>( DetScope::Identity(&a_buf), &sidecar_key(KV_PREFIX_BLOCKED, &counterparty), @@ -1636,7 +1838,7 @@ mod tests { .unwrap(); kv.put::<()>( DetScope::Identity(&a_buf), - &sidecar_key(KV_PREFIX_REJECTED, &counterparty), + &sidecar_key(KV_PREFIX_DECLINED, &counterparty), &(), ) .unwrap(); @@ -1646,7 +1848,7 @@ mod tests { assert!(kv_contains( &kv, &owner_a, - KV_PREFIX_REJECTED, + KV_PREFIX_DECLINED, &counterparty )); @@ -1656,19 +1858,35 @@ mod tests { "owner B must not see owner A's blocked marker" ); assert!( - !kv_contains(&kv, &owner_b, KV_PREFIX_REJECTED, &counterparty), - "owner B must not see owner A's rejected marker" + !kv_contains(&kv, &owner_b, KV_PREFIX_DECLINED, &counterparty), + "owner B must not see owner A's decline marker" ); let now_ms: u64 = 1_000_000_000_000; let created_at_ms: u64 = now_ms - 60_000; assert_eq!( - derive_request_status(&owner_a, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner_a, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Rejected, - "A's own rejection colours A's view" + "A's own decline colours A's view" ); assert_eq!( - derive_request_status(&owner_b, &counterparty, false, created_at_ms, now_ms, &kv), + derive_request_status( + &owner_b, + &counterparty, + ContactRequestDirection::Received, + false, + created_at_ms, + now_ms, + &kv + ), ContactRequestStatus::Pending, "B's view of the same counterparty is unaffected" ); @@ -1926,7 +2144,7 @@ mod tests { ) .unwrap(); - // Four Identity-scoped overlays under the owner. + // Five Identity-scoped overlays under the owner. kv.put::( DetScope::Identity(&owner), &sidecar_key(KV_PREFIX_PRIVATE, &contact), @@ -1953,7 +2171,13 @@ mod tests { .unwrap(); kv.put::<()>( DetScope::Identity(&owner), - &sidecar_key(KV_PREFIX_REJECTED, &contact), + &sidecar_key(KV_PREFIX_DECLINED, &contact), + &(), + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_WITHDRAWN, &contact), &(), ) .unwrap(); @@ -1975,7 +2199,7 @@ mod tests { let owned = kv .list(DetScope::Identity(&owner), Some("det:dashpay:")) .expect("owner sidecar listing must succeed"); - assert_eq!(owned.len(), 4, "four owner-scoped overlays: {owned:?}"); + assert_eq!(owned.len(), 5, "five owner-scoped overlays: {owned:?}"); } /// D4d-Sweep2: the combined clear (Global prefix sweep + per-owner @@ -1987,7 +2211,7 @@ mod tests { let owner = id_from_byte(1).to_buffer(); let contact = id_from_byte(2); - // A Global overlay (timestamps) plus the four owner-scoped overlays. + // A Global overlay (timestamps) plus the five owner-scoped overlays. kv.put::<(i64, i64)>( DetScope::Global, &sidecar_key(KV_PREFIX_TIMESTAMPS, &contact), @@ -2002,7 +2226,13 @@ mod tests { .unwrap(); kv.put::<()>( DetScope::Identity(&owner), - &sidecar_key(KV_PREFIX_REJECTED, &contact), + &sidecar_key(KV_PREFIX_DECLINED, &contact), + &(), + ) + .unwrap(); + kv.put::<()>( + DetScope::Identity(&owner), + &sidecar_key(KV_PREFIX_WITHDRAWN, &contact), &(), ) .unwrap(); @@ -2041,7 +2271,8 @@ mod tests { KV_PREFIX_PRIVATE, KV_PREFIX_ADDRESS_INDEX, KV_PREFIX_BLOCKED, - KV_PREFIX_REJECTED, + KV_PREFIX_DECLINED, + KV_PREFIX_WITHDRAWN, ] { for k in kv.list(DetScope::Identity(&owner), Some(prefix)).unwrap() { kv.delete(DetScope::Identity(&owner), &k).unwrap(); diff --git a/tests/backend-e2e/dashpay_tasks.rs b/tests/backend-e2e/dashpay_tasks.rs index 867d164ea..b3d703287 100644 --- a/tests/backend-e2e/dashpay_tasks.rs +++ b/tests/backend-e2e/dashpay_tasks.rs @@ -192,7 +192,7 @@ async fn tc_034_load_contacts_empty() { BackendTaskSuccessResult::DashPayContacts(contacts) => { tracing::info!("TC-034: LoadContacts returned {} contacts", contacts.len()); } - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts) => { + BackendTaskSuccessResult::DashPayContactsWithInfo { contacts, .. } => { tracing::info!( "TC-034: LoadContactsWithInfo returned {} contacts", contacts.len() @@ -253,7 +253,7 @@ async fn tc_046_load_contacts_offline_serves_cache() { .expect("TC-046: offline read should not fail"); let contacts = match result { - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts) => contacts, + BackendTaskSuccessResult::DashPayContactsWithInfo { contacts, .. } => contacts, other => panic!("TC-046: expected DashPayContactsWithInfo, got: {:?}", other), }; tracing::info!( @@ -310,7 +310,9 @@ async fn tc_035_load_contact_requests_empty() { .expect("LoadContactRequests should not fail"); match result { - BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing } => { + BackendTaskSuccessResult::DashPayContactRequests { + incoming, outgoing, .. + } => { tracing::info!( "TC-035: LoadContactRequests returned {} incoming, {} outgoing", incoming.len(), @@ -408,7 +410,9 @@ async fn step_load_contact_requests( .expect("LoadContactRequests should not fail"); match result { - BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing } => { + BackendTaskSuccessResult::DashPayContactRequests { + incoming, outgoing, .. + } => { tracing::info!( "Step 2: B has {} incoming, {} outgoing requests", incoming.len(), @@ -746,7 +750,7 @@ async fn tc_037_dashpay_contact_lifecycle() { contacts.len() ); } - BackendTaskSuccessResult::DashPayContactsWithInfo(contacts) => { + BackendTaskSuccessResult::DashPayContactsWithInfo { contacts, .. } => { assert!( !contacts.is_empty(), "TC-037: no contacts found but no pending request either — test state inconsistent" From c61da82f63ace06178302739d9101c761c33ccb7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:06:31 +0000 Subject: [PATCH 20/30] fix(migration): stop the legacy import from losing top-ups, votes and warnings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/user-stories.md | 2 + src/app.rs | 6 + src/app/reconcilers.rs | 30 +- src/backend_task/migration/finish_unwire.rs | 329 ++++++++++++++++++-- src/backend_task/migration/mod.rs | 9 + src/context/identity_db.rs | 71 ++++- src/database/legacy_import.rs | 146 ++++++++- tests/kittest/migration_banner.rs | 30 +- 8 files changed, 581 insertions(+), 42 deletions(-) diff --git a/docs/user-stories.md b/docs/user-stories.md index 255fb3d42..45220ee45 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -680,6 +680,8 @@ As a masternode operator, I want my previously scheduled DPNS votes to survive a - Scheduled votes stored before the upgrade remain visible and executable afterward. - The first launch after the upgrade imports them from the previous version's storage, keeping each vote's choice, timestamp, and already-cast state. A vote that cannot be read is reported in a banner, with the recovery action, rather than dropped silently — and never blocks the wallet migration that restores access to funds. +- A single unreadable vote row costs only itself: the readable votes in the same batch still import. +- The report of unreadable votes returns on every launch until it is explicitly acknowledged, so a vote whose deadline is still open cannot lose its only notice to a missed or dismissed banner. --- diff --git a/src/app.rs b/src/app.rs index 913c6be16..4df8b4b42 100644 --- a/src/app.rs +++ b/src/app.rs @@ -57,6 +57,12 @@ use tokio::sync::mpsc as tokiompsc; /// risking a typo collision. Exposed for kittest coverage. pub const MIGRATION_RETRY_ACTION_ID: &str = "migration:retry:finish_unwire"; +/// Banner action id pushed when the user acknowledges the unreadable-vote +/// warning. Until it fires, the warning is re-raised on every launch — a +/// dismissed banner is not an acknowledgement, because the vote it names may +/// still have a live deadline. Exposed for kittest coverage. +pub const MIGRATION_VOTES_ACK_ACTION_ID: &str = "migration:ack:unreadable_votes"; + /// Action id for the SPV-sync block's "Continue in the background" escape button. /// SPV sync is **unbounded** — with no peers it stays Connecting/Syncing forever /// with no terminal signal — so a button-less hard block would trap the user diff --git a/src/app/reconcilers.rs b/src/app/reconcilers.rs index 6ebec49f1..e0ef467f1 100644 --- a/src/app/reconcilers.rs +++ b/src/app/reconcilers.rs @@ -31,9 +31,10 @@ use crate::ui::components::{ use super::{ COLD_START_BACKEND_READY_TIMEOUT, COLD_START_STUCK_MESSAGE, MIGRATION_RETRY_ACTION_ID, - SPV_CONNECTING_DESCRIPTION, SPV_CONTINUE_BACKGROUND_ACTION, SPV_SYNCING_DESCRIPTION, - SpvBlockStep, cold_start_backend_wait_timed_out, migration_running_text, - migration_unreadable_votes_text, should_dispatch_cold_start, spv_block_step, + MIGRATION_VOTES_ACK_ACTION_ID, SPV_CONNECTING_DESCRIPTION, SPV_CONTINUE_BACKGROUND_ACTION, + SPV_SYNCING_DESCRIPTION, SpvBlockStep, cold_start_backend_wait_timed_out, + migration_running_text, migration_unreadable_votes_text, should_dispatch_cold_start, + spv_block_step, }; /// Drives platform-level accessibility (AccessKit) activation on the first @@ -476,15 +477,18 @@ impl MigrationReconciler { } MigrationState::SucceededWithUnreadableVotes { count } => { // The wallets landed; only the corrupt vote rows did not. A - // Warning (not Error) with no retry action: the drain is done and - // re-reading a corrupt row cannot help. Sticky, so the user who - // stepped away still learns the votes need re-scheduling. + // Warning (not Error): the drain is done and re-reading a corrupt + // row cannot help, so there is no retry. Sticky, and re-raised on + // every launch until the user clicks the acknowledge action — a + // vote whose deadline still matters must not lose its only notice + // because the user was away when the banner appeared. let handle = MessageBanner::set_global( ctx, migration_unreadable_votes_text(count), MessageType::Warning, ); handle.disable_auto_dismiss(); + handle.with_action("Got it", MIGRATION_VOTES_ACK_ACTION_ID); self.banner_handle = Some(handle); } MigrationState::Failed { error } => { @@ -533,9 +537,10 @@ impl MigrationReconciler { } } - /// Drain pending banner-action clicks. The only registered action is the + /// Drain pending banner-action clicks. Two actions are registered: the /// migration Retry, which re-dispatches `FinishUnwire` after resetting the - /// cold-start guard — returned for `AppState` to dispatch. + /// cold-start guard, and the unreadable-vote acknowledgement, which clears the + /// durable warning. Both are returned for `AppState` to dispatch. pub(super) fn drain_actions( &mut self, ctx: &egui::Context, @@ -555,6 +560,15 @@ impl MigrationReconciler { self.last_state = None; self.dispatched.remove(&network); task = Some(BackendTask::MigrationTask(MigrationTask::FinishUnwire)); + } else if action_id == MIGRATION_VOTES_ACK_ACTION_ID { + tracing::info!( + target = "migration::cold_start", + ?network, + "User acknowledged the unreadable-vote warning", + ); + task = Some(BackendTask::MigrationTask( + MigrationTask::AcknowledgeUnreadableVotes, + )); } else { tracing::warn!( target = "ui::banner", diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 13252972b..9ee82338e 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -166,6 +166,27 @@ pub enum MigrationError { source: Box, }, + /// The legacy top-up history could not be carried across — the legacy table + /// is unreadable, or the k/v store rejected the write. Audit trail, not + /// funds, so it never blocks the wallet drain; but the app-data sentinel + /// stays unwritten so the next launch retries the idempotent import rather + /// than recording the loss as final. + #[error("could not save the top-up history from the previous version")] + TopUpHistoryWrite { + #[source] + source: Box, + }, + + /// Could not read, write or clear the durable unreadable-vote warning. That + /// record is what re-raises the warning on every launch until the user + /// acknowledges it, so a failure here is surfaced rather than dropped — a + /// silently-lost warning is a silently-missed vote deadline. + #[error("could not access the unreadable-vote warning")] + VoteWarningRecord { + #[source] + source: KvAdapterError, + }, + /// The wallet backend was not yet wired when the migration ran. /// This is a hard configuration bug: the orchestrator runs after /// `ensure_wallet_backend`, so this should never fire in @@ -243,6 +264,10 @@ impl MigrationError { /// row that cannot be imported must never stand between the user and their /// seeds. /// +/// A pending [`UnreadableVotesWarning`] is re-published on every launch — not +/// only the one that discovered it — until [`acknowledge_unreadable_votes`] +/// clears it. +/// /// # Errors /// /// [`TaskError::MigrationFailed`] when the wallet drain fails (the completion @@ -290,16 +315,19 @@ pub async fn run(app_context: &Arc) -> Result { let app_data = app_data?; let moved_data = wallet_moved || app_data.moved_data(); - if app_data.votes_unreadable > 0 { + // The warning is read back from storage rather than taken from this pass's + // counters: on every launch after the discovery run the import short-circuits + // on its sentinel and reports zero, yet the votes it could not decode still + // need re-scheduling. Re-published until the user acknowledges it. + if let Some(warning) = read_vote_warning(&app_context.app_kv(), app_context.network)? { tracing::warn!( target = "migration::finish_unwire", - unreadable = app_data.votes_unreadable, - imported = app_data.votes_imported, + unreadable = warning.count, network = ?app_context.network, "Some legacy scheduled votes could not be decoded; they stay in the previous version's data.db and must be scheduled again", ); status.set_state(MigrationState::SucceededWithUnreadableVotes { - count: app_data.votes_unreadable, + count: warning.count, }); return Ok(moved_data); } @@ -458,6 +486,85 @@ pub fn app_data_sentinel_key_for(network: Network) -> String { format!("det:migration:app_data:{}:v1", network_prefix(network)) } +/// Per-network key of the un-acknowledged unreadable-vote warning. Distinct +/// from the app-data sentinel: the sentinel records that the import *ran*, this +/// record that the user has not yet been *told* what it could not carry across. +fn vote_warning_key_for(network: Network) -> String { + format!( + "det:migration:unreadable_votes:{}:v1", + network_prefix(network) + ) +} + +/// Durable "some scheduled votes could not be read" warning. +/// +/// The import runs once (the app-data sentinel short-circuits every later +/// launch), so the pass counters exist for exactly one launch. A user who was +/// away, or who dismissed the banner without reading it, would never hear about +/// it again — while the vote it names may still have a live deadline. This +/// record outlives the pass: [`run`] re-publishes it on every launch until +/// [`acknowledge_unreadable_votes`] clears it. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct UnreadableVotesWarning { + /// Legacy vote rows the import could not decode. Never `0` — a zero-count + /// warning is not written at all. + pub count: u32, +} + +/// The pending unreadable-vote warning for `network`, if the user has not +/// acknowledged it yet. +fn read_vote_warning( + app_kv: &crate::wallet_backend::DetKv, + network: Network, +) -> Result, MigrationError> { + app_kv + .get::(DetScope::Global, &vote_warning_key_for(network)) + .map_err(|source| MigrationError::VoteWarningRecord { source }) +} + +/// Record `count` unreadable vote rows as a pending warning. A zero count +/// writes nothing — there is nothing to tell the user. +/// +/// Written BEFORE the app-data sentinel: a crash between the two re-runs the +/// idempotent import, whereas the reverse order would lose the warning for good. +fn write_vote_warning( + app_kv: &crate::wallet_backend::DetKv, + network: Network, + count: u32, +) -> Result<(), MigrationError> { + if count == 0 { + return Ok(()); + } + app_kv + .put( + DetScope::Global, + &vote_warning_key_for(network), + &UnreadableVotesWarning { count }, + ) + .map_err(|source| MigrationError::VoteWarningRecord { source }) +} + +/// Retire the unreadable-vote warning for the active network: the user has read +/// it. Clears the durable record so later launches stay quiet, and drops the +/// banner. The legacy rows in `data.db` are untouched — only the notice is +/// retired, so a build with a better decoder can still recover the votes. +pub fn acknowledge_unreadable_votes(app_context: &Arc) -> Result<(), TaskError> { + let network = app_context.network; + app_context + .app_kv() + .delete(DetScope::Global, &vote_warning_key_for(network)) + .map_err(|source| MigrationError::VoteWarningRecord { source })?; + tracing::info!( + target = "migration::finish_unwire", + network = ?network, + "User acknowledged the unreadable-vote warning", + ); + app_context + .migration_status() + .set_state(MigrationState::Idle); + Ok(()) +} + /// Import the DET-owned rows the wallet drain never touched: scheduled DPNS /// votes (deadline-critical) and top-up history (audit trail). /// @@ -542,6 +649,13 @@ fn migrate_app_data(app_context: &Arc) -> Result { + let mut first_error = None; for (identity_id, history) in top_ups { let id = dash_sdk::platform::Identifier::from(identity_id); match save_top_ups(&id, &history) { @@ -627,20 +746,28 @@ where outcome.top_up_identities_imported = outcome.top_up_identities_imported.saturating_add(1) } - Err(e) => tracing::warn!( - target = "migration::finish_unwire", - identity = %hex::encode(identity_id), - error = ?e, - "Could not write top-up history; the audit trail stays in legacy data.db", - ), + Err(e) => { + tracing::warn!( + target = "migration::finish_unwire", + identity = %hex::encode(identity_id), + error = ?e, + "Could not write top-up history; the import will be retried on the next launch", + ); + first_error.get_or_insert(e); + } } } + first_error.map(|source| MigrationError::TopUpHistoryWrite { + source: Box::new(source), + }) } - Err(e) => tracing::warn!( - target = "migration::finish_unwire", - error = ?e, - "Could not read legacy top-up history; the audit trail stays in legacy data.db", - ), + Err(source) => Some(MigrationError::LegacyDbRead { + table: "top_up", + source, + }), + }; + if let Some(failure) = failure { + return Err(failure); } Ok(outcome) @@ -1844,6 +1971,64 @@ mod tests { assert_eq!(votes.borrow()[0].contested_name, "alice"); } + /// A top-up write failure must fail the pass. It is audit trail, so it + /// never blocks the wallet drain — but swallowing it would let the + /// app-data sentinel record "done" over a history that never landed, + /// making a transient k/v error permanent. The votes that already + /// imported stay imported; the retry is idempotent. + #[test] + fn top_up_write_failure_fails_the_pass() { + let conn = legacy_conn(); + + let result = migrate_app_data_from_conn( + &conn, + Network::Testnet, + &BTreeSet::new(), + |_| Ok(()), + |_, _| Err(TaskError::WalletNotFound), + ); + + assert!( + matches!(result, Err(MigrationError::TopUpHistoryWrite { .. })), + "a top-up write failure must reach the caller so the sentinel is withheld, got {result:?}", + ); + } + + /// A structurally unreadable `top_up` table (here: no `amount` column) + /// fails the pass for the same reason — the caller must withhold the + /// sentinel rather than declare the history migrated. + #[test] + fn top_up_read_failure_fails_the_pass() { + let conn = Connection::open_in_memory().expect("open"); + conn.execute_batch( + "CREATE TABLE identity (id BLOB PRIMARY KEY, network TEXT NOT NULL); + CREATE TABLE top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL + );", + ) + .expect("schema"); + + let result = migrate_app_data_from_conn( + &conn, + Network::Testnet, + &BTreeSet::new(), + |_| Ok(()), + |_, _| Ok(()), + ); + + assert!( + matches!( + result, + Err(MigrationError::LegacyDbRead { + table: "top_up", + .. + }) + ), + "an unreadable top-up table must reach the caller, got {result:?}", + ); + } + /// A fresh install has none of these tables — a no-op, not an error. #[test] fn missing_tables_are_a_no_op() { @@ -3634,6 +3819,112 @@ mod tests { backend.shutdown().await; } + /// A top-up import failure must leave the app-data sentinel unwritten, so + /// the next launch retries the (idempotent) import. Writing the sentinel + /// anyway would make a one-off failure permanent: the fast path short-circuits + /// on it forever and the history never comes across. Staged with a legacy + /// `top_up` table that has no `amount` column, which is what a structurally + /// damaged legacy file looks like from the reader's side. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn app_data_sentinel_is_withheld_when_top_ups_cannot_be_imported() { + use dash_sdk::dpp::dashcore::Network; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + let network = Network::Testnet; + let identity = [0x44u8; 32]; + + { + // `identity` comes from the modern schema; only `top_up` is staged, and + // without its `amount` column — the reader's prepare fails on it. + let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); + conn.execute_batch( + "CREATE TABLE top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL + );", + ) + .expect("broken legacy top-up schema"); + conn.execute( + "INSERT INTO top_up (identity_id, top_up_index) VALUES (?1, 0)", + rusqlite::params![identity.as_slice()], + ) + .expect("top-up row"); + } + + wire_backend(&ctx).await; + let backend = ctx.wallet_backend().expect("backend wired"); + + let result = migrate_app_data(&ctx); + + assert!( + result.is_err(), + "an unimportable top-up history must fail the app-data pass, got {result:?}", + ); + assert!( + ctx.app_kv() + .get::(DetScope::Global, &app_data_sentinel_key_for(network)) + .expect("read app-data sentinel") + .is_none(), + "the app-data sentinel must not be written when the import failed — \ + the next launch has to retry it", + ); + + backend.shutdown().await; + } + + /// Fix-8 — the unreadable-vote warning is durable. The discovery run records + /// it; every later launch re-publishes it from that record even though both + /// sentinels short-circuit the passes, so a user who was away when the + /// migration finished still learns that a vote with a live deadline needs + /// re-scheduling. Only an explicit acknowledgement retires it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unreadable_vote_warning_is_republished_until_acknowledged() { + use dash_sdk::dpp::dashcore::Network; + + let tmp = tempfile::tempdir().expect("tempdir"); + let ctx = fresh_app_context(tmp.path()); + let network = Network::Testnet; + + seed_legacy_wallet(&ctx, &[0xC5u8; 64], "funds", network); + seed_legacy_votes(&ctx, &[0x33u8; 32], &[("corrupt", "Nonsense")], network); + + wire_backend(&ctx).await; + let backend = ctx.wallet_backend().expect("backend wired"); + + run(&ctx).await.expect("first launch"); + assert_eq!( + *ctx.migration_status().state(), + MigrationState::SucceededWithUnreadableVotes { count: 1 }, + "the discovery run surfaces the warning", + ); + + // A later launch starts from a fresh in-memory status, and both sentinels + // now short-circuit their passes — the warning must come from storage. + ctx.migration_status().set_state(MigrationState::Idle); + run(&ctx).await.expect("second launch"); + assert_eq!( + *ctx.migration_status().state(), + MigrationState::SucceededWithUnreadableVotes { count: 1 }, + "a warning the user may have missed must survive a restart", + ); + + acknowledge_unreadable_votes(&ctx).expect("acknowledge"); + assert!( + matches!(*ctx.migration_status().state(), MigrationState::Idle), + "acknowledging clears the banner immediately", + ); + + ctx.migration_status().set_state(MigrationState::Idle); + run(&ctx).await.expect("third launch"); + assert!( + matches!(*ctx.migration_status().state(), MigrationState::Idle), + "an acknowledged warning must never come back", + ); + + backend.shutdown().await; + } + /// Funds-safety invariant (Fix #3): a run that cannot finish — here because /// no wallet backend is wired in the fixture — MUST return `Err` and MUST /// NOT write the completion sentinel, so the migration retries on a later diff --git a/src/backend_task/migration/mod.rs b/src/backend_task/migration/mod.rs index fef08a8f6..67d0b680b 100644 --- a/src/backend_task/migration/mod.rs +++ b/src/backend_task/migration/mod.rs @@ -30,6 +30,11 @@ pub enum MigrationTask { /// completion sentinel exists in `det-app.sqlite`, subsequent calls /// return `Success` immediately without touching the legacy file. FinishUnwire, + /// Retire the "some scheduled votes could not be read" warning for the + /// active network. The warning is re-raised on every launch until the user + /// acknowledges it, so this is the one gesture that stops it — the legacy + /// vote rows themselves are never touched. + AcknowledgeUnreadableVotes, } impl AppContext { @@ -67,6 +72,10 @@ impl AppContext { Err(task_error) } }, + MigrationTask::AcknowledgeUnreadableVotes => { + finish_unwire::acknowledge_unreadable_votes(self)?; + Ok(BackendTaskSuccessResult::Refresh) + } } } } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index d70e8c3d2..dc3275be8 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -69,6 +69,28 @@ fn top_up_err(source: KvAdapterError) -> TaskError { TaskError::TopUpHistoryStorage { source } } +/// Merge `top_ups` into the stored history of `identity_id` (read-merge-write). +/// +/// Callers hold a partial view of the history — the top-up flow carries the +/// entries it hydrated plus the one it just confirmed, and the legacy-data +/// import carries only what `data.db` held — so a replacing write would drop +/// every entry the caller never saw. On a colliding index the caller's value +/// wins: it is the fresher of the two. Removal is not expressible here; the +/// purge path deletes the whole key instead. +fn save_top_ups_in( + kv: &DetKv, + identity_id: &[u8; 32], + top_ups: &std::collections::BTreeMap, +) -> std::result::Result<(), TaskError> { + let scope = DetScope::Identity(identity_id); + let mut merged = kv + .get::>(scope, TOP_UPS_KEY) + .map_err(top_up_err)? + .unwrap_or_default(); + merged.extend(top_ups.iter().map(|(index, amount)| (*index, *amount))); + kv.put(scope, TOP_UPS_KEY, &merged).map_err(top_up_err) +} + /// Validate a raw voter id and return it as the `[u8; 32]` the /// [`DetScope::Identity`] scope borrows. Surfaces a typed error rather /// than panicking on a wrong-length slice. @@ -784,16 +806,13 @@ impl AppContext { } /// Persist the running top-up history for an identity into the - /// per-network wallet k/v store. + /// per-network wallet k/v store. Merges — see [`save_top_ups_in`]. pub fn save_top_ups( &self, identity_id: &Identifier, top_ups: &std::collections::BTreeMap, ) -> std::result::Result<(), TaskError> { - let kv = self.det_kv()?; - let id = identity_id.to_buffer(); - kv.put(DetScope::Identity(&id), TOP_UPS_KEY, top_ups) - .map_err(top_up_err) + save_top_ups_in(&self.det_kv()?, &identity_id.to_buffer(), top_ups) } pub fn get_identity_by_id( @@ -1351,6 +1370,48 @@ mod tests { assert_eq!(got, map); } + /// A legacy import carries only the entries it found in `data.db`. It must + /// union them into whatever the user has recorded since — a top-up made + /// between two migration passes is real money moved, and a replacing write + /// would erase its record. + #[test] + fn save_top_ups_merges_into_the_stored_history() { + let kv = empty_kv(); + let a = id(1); + + // The user tops up in the new build; the entry lands at index 1. + save_top_ups_in(&kv, &a, &std::collections::BTreeMap::from([(1u32, 250u64)])).unwrap(); + // A late migration pass replays the legacy history, which knows only index 0. + save_top_ups_in(&kv, &a, &std::collections::BTreeMap::from([(0u32, 100u64)])).unwrap(); + + let got: std::collections::BTreeMap = kv + .get(DetScope::Identity(&a), TOP_UPS_KEY) + .unwrap() + .unwrap(); + assert_eq!( + got, + std::collections::BTreeMap::from([(0u32, 100u64), (1u32, 250u64)]), + "the entry recorded between the passes must survive the import", + ); + } + + /// On a colliding index the caller's value wins: the top-up flow writes the + /// amount it just confirmed on-chain, which is fresher than any stored copy. + #[test] + fn save_top_ups_incoming_value_wins_on_a_colliding_index() { + let kv = empty_kv(); + let a = id(1); + + save_top_ups_in(&kv, &a, &std::collections::BTreeMap::from([(0u32, 100u64)])).unwrap(); + save_top_ups_in(&kv, &a, &std::collections::BTreeMap::from([(0u32, 999u64)])).unwrap(); + + let got: std::collections::BTreeMap = kv + .get(DetScope::Identity(&a), TOP_UPS_KEY) + .unwrap() + .unwrap(); + assert_eq!(got, std::collections::BTreeMap::from([(0u32, 999u64)])); + } + // --------------------------------------------------------------- // Cleanup: purge drains the whole Identity scope. // --------------------------------------------------------------- diff --git a/src/database/legacy_import.rs b/src/database/legacy_import.rs index 665ee13b5..6e3288f7d 100644 --- a/src/database/legacy_import.rs +++ b/src/database/legacy_import.rs @@ -33,7 +33,8 @@ const LEGACY_MAINNET_ALIAS: &str = "dash"; /// Outcome of one legacy scheduled-vote read. /// /// `unreadable` counts rows the reader could not decode into a -/// [`ScheduledDPNSVote`] (corrupt voter id, unparseable vote choice). They +/// [`ScheduledDPNSVote`] — a malformed column (NULL, wrong type, out-of-range +/// integer) as much as a corrupt voter id or an unparseable vote choice. They /// are reported rather than silently dropped so the caller can refuse to /// mark the import complete — a dropped vote is a missed vote window. #[derive(Debug, Default, PartialEq)] @@ -185,11 +186,24 @@ pub(crate) fn read_scheduled_votes( let mut rows = rows; while let Some(row) = rows.next()? { - let voter_id: Vec = row.get(0)?; - let contested_name: String = row.get(1)?; - let vote_choice: String = row.get(2)?; - let unix_timestamp: u64 = row.get(3)?; - let executed: i64 = row.get(4)?; + // Column decoding is per-row, like the domain decoding below it: a NULL, + // a type mismatch or an out-of-range integer (a negative `time` fails the + // `u64` range check) is corruption of ONE row. Propagating it would + // discard every vote already read in this pass and turn a + // warning-and-skip into a hard migration failure. + let decoded = decode_scheduled_vote_columns(row); + let (voter_id, contested_name, vote_choice, unix_timestamp, executed) = match decoded { + Ok(columns) => columns, + Err(e) => { + tracing::warn!( + target = "database::legacy_import", + error = ?e, + "Skipping legacy scheduled vote with an unreadable column", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + } + }; let Ok(voter_id) = Identifier::from_bytes(&voter_id) else { tracing::warn!( @@ -222,6 +236,28 @@ pub(crate) fn read_scheduled_votes( Ok(out) } +/// Decode the five raw columns of one legacy `scheduled_votes` row. Kept +/// separate so a conversion failure is a `Result` the row loop can count and +/// skip, rather than a `?` that escapes [`read_scheduled_votes`]. +fn decode_scheduled_vote_columns( + row: &rusqlite::Row<'_>, +) -> rusqlite::Result<(Vec, String, String, u64, i64)> { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) +} + +/// Decode the three raw columns of one legacy `top_up` row. Same contract as +/// [`decode_scheduled_vote_columns`]: a malformed column costs its own row, not +/// the whole read. +fn decode_top_up_columns(row: &rusqlite::Row<'_>) -> rusqlite::Result<(Vec, u32, u64)> { + Ok((row.get(0)?, row.get(1)?, row.get(2)?)) +} + /// Read the top-up history of every identity on `network`. /// /// The legacy `top_up` table carries no network column, so rows are scoped @@ -245,9 +281,17 @@ pub(crate) fn read_top_ups(conn: &Connection, network: Network) -> rusqlite::Res let mut grouped: BTreeMap<[u8; 32], BTreeMap> = BTreeMap::new(); while let Some(row) = rows.next()? { - let identity_id: Vec = row.get(0)?; - let index: u32 = row.get(1)?; - let amount: u64 = row.get(2)?; + let (identity_id, index, amount) = match decode_top_up_columns(row) { + Ok(columns) => columns, + Err(e) => { + tracing::warn!( + target = "database::legacy_import", + error = ?e, + "Skipping legacy top-up row with an unreadable column", + ); + continue; + } + }; let Ok(identity_id) = <[u8; 32]>::try_from(identity_id.as_slice()) else { tracing::warn!( target = "database::legacy_import", @@ -639,6 +683,51 @@ mod tests { assert_eq!(read.unreadable, 2, "both corrupt rows are reported"); } + /// A malformed column is the same class of damage as an undecodable vote + /// choice: skip the row, count it, keep going. Aborting the read would + /// discard every vote already accumulated in the same pass — the valid rows + /// on both sides of the bad one — and turn a warning into a hard failure. + /// Both shapes SQLite can hand back are covered: an out-of-range integer + /// (a negative `time` where a `u64` belongs) and a type mismatch (a blob in + /// the `vote_choice` text column). + #[test] + fn scheduled_votes_malformed_column_skips_only_its_own_row() { + let conn = Connection::open_in_memory().unwrap(); + create_scheduled_votes_table(&conn, true); + let voter = [0x11u8; 32]; + + insert_vote(&conn, &voter, "before", "Lock", 0, Some("testnet")); + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, 'negative-time', 'Lock', -1, 0, 'testnet')", + rusqlite::params![voter.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO scheduled_votes + (identity_id, contested_name, vote_choice, time, executed, network) + VALUES (?1, 'blob-choice', ?2, 1700000000, 0, 'testnet')", + rusqlite::params![voter.as_slice(), vec![0xFFu8; 4]], + ) + .unwrap(); + insert_vote(&conn, &voter, "after", "Abstain", 0, Some("testnet")); + + let read = read_scheduled_votes(&conn, Network::Testnet).unwrap(); + + assert_eq!(read.unreadable, 2, "both malformed rows are reported"); + let names: Vec<&str> = read + .votes + .iter() + .map(|v| v.contested_name.as_str()) + .collect(); + assert_eq!( + names, + vec!["before", "after"], + "the valid votes on both sides of a malformed row still import", + ); + } + #[test] fn scheduled_votes_absent_table_reads_empty() { let conn = Connection::open_in_memory().unwrap(); @@ -683,6 +772,45 @@ mod tests { assert_eq!(top_ups[0].1, BTreeMap::from([(0, 1000), (1, 2000)])); } + /// A malformed amount (negative, so out of range for `u64`) skips its own + /// row rather than aborting the read and losing every other identity's + /// audit trail. + #[test] + fn top_ups_malformed_column_skips_only_its_own_row() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch( + "CREATE TABLE identity (id BLOB PRIMARY KEY, network TEXT NOT NULL); + CREATE TABLE top_up ( + identity_id BLOB NOT NULL, + top_up_index INTEGER NOT NULL, + amount INTEGER NOT NULL, + PRIMARY KEY (identity_id, top_up_index) + );", + ) + .unwrap(); + let mine = [0xAAu8; 32]; + conn.execute( + "INSERT INTO identity (id, network) VALUES (?1, 'testnet')", + rusqlite::params![mine.as_slice()], + ) + .unwrap(); + conn.execute( + "INSERT INTO top_up (identity_id, top_up_index, amount) VALUES + (?1, 0, 1000), (?1, 1, -5), (?1, 2, 2000)", + rusqlite::params![mine.as_slice()], + ) + .unwrap(); + + let top_ups = read_top_ups(&conn, Network::Testnet).unwrap(); + + assert_eq!(top_ups.len(), 1); + assert_eq!( + top_ups[0].1, + BTreeMap::from([(0, 1000), (2, 2000)]), + "the readable top-ups around a malformed row still import", + ); + } + #[test] fn top_ups_absent_table_reads_empty() { let conn = Connection::open_in_memory().unwrap(); diff --git a/tests/kittest/migration_banner.rs b/tests/kittest/migration_banner.rs index 939fc10f5..eafffa977 100644 --- a/tests/kittest/migration_banner.rs +++ b/tests/kittest/migration_banner.rs @@ -6,7 +6,8 @@ //! needing a full `AppState` harness. use dash_evo_tool::app::{ - MIGRATION_RETRY_ACTION_ID, migration_running_text, migration_unreadable_votes_text, + MIGRATION_RETRY_ACTION_ID, MIGRATION_VOTES_ACK_ACTION_ID, migration_running_text, + migration_unreadable_votes_text, }; use dash_evo_tool::context::migration_status::MigrationStep; use dash_evo_tool::ui::MessageType; @@ -184,3 +185,30 @@ fn unreadable_votes_banner_warns_without_a_retry_action() { "a completed drain must not offer a retry the user cannot benefit from", ); } + +/// Fix-8 — the warning carries an explicit acknowledgement, and clicking it +/// enqueues the ack action id. The app loop drains that id and clears the +/// durable warning record; until then the banner returns on every launch, so a +/// vote whose deadline still matters cannot lose its only notice to a stray +/// dismissal. +#[test] +fn unreadable_votes_banner_acknowledgement_enqueues_action() { + let text = migration_unreadable_votes_text(2); + let mut harness = Harness::builder() + .with_size(egui::vec2(600.0, 220.0)) + .build_ui(move |ui| { + let handle = MessageBanner::set_global(ui.ctx(), text.clone(), MessageType::Warning); + handle.with_action("Got it", MIGRATION_VOTES_ACK_ACTION_ID); + MessageBanner::show_global(ui); + }); + harness.run(); + assert!(MessageBanner::take_action(&harness.ctx).is_none()); + + harness.get_by_label("Got it").click(); + harness.run(); + + assert_eq!( + MessageBanner::take_action(&harness.ctx).as_deref(), + Some(MIGRATION_VOTES_ACK_ACTION_ID), + ); +} From 05f6897b08f5eee45ad2640d1f409a9e3fc96be0 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek Date: Mon, 13 Jul 2026 12:16:03 +0000 Subject: [PATCH 21/30] 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 --- .../2026-05-28-migration-tool/notes.md | 32 +- .../design.md | 446 ++++++++++++++++++ 2 files changed, 466 insertions(+), 12 deletions(-) create mode 100644 docs/ai-design/2026-07-13-legacy-identity-migration/design.md diff --git a/docs/ai-design/2026-05-28-migration-tool/notes.md b/docs/ai-design/2026-05-28-migration-tool/notes.md index e045163f0..463e3ef56 100644 --- a/docs/ai-design/2026-05-28-migration-tool/notes.md +++ b/docs/ai-design/2026-05-28-migration-tool/notes.md @@ -203,19 +203,27 @@ idempotency is confirmed. ### `identity` (DET source file: `src/database/identities.rs`) -- **Source:** `identity` in `data.db` -- **Destination:** `identities.entry_blob` (typed BLOB column) in `platform-wallet-storage` -- **Mapping:** Deserialize DET's stored identity representation → serialize as - bincode-encoded `QualifiedIdentity` with a leading version byte prepended -- **Per-network split:** Yes -- **Gotchas:** Upstream schema uses a leading version byte in `entry_blob` for - forward/backward compatibility — this byte must be present and set correctly, or upstream - deserialization will silently produce garbage. Confirm the byte format with the - platform-wallet-storage author before implementing. This is the highest-risk table in the - migration. +> **SUPERSEDED (2026-07-13).** Design and task breakdown now live in +> `docs/ai-design/2026-07-13-legacy-identity-migration/design.md`. The entry below is kept +> for context; two of its claims are wrong. The destination is **not** upstream's +> `identities.entry_blob` — commit `b14bf32c` moved it to DET's own per-network k/v +> (`det:identity:v1` under `DetScope::Identity`, roster at `det:identity_index:v1`). And the +> version byte needs no agreement with the platform-wallet-storage author: `DetKv::put` +> prepends `kv::SCHEMA_VERSION` automatically, so an importer that goes through +> `AppContext::insert_local_qualified_identity` gets it for free. + +- **Source:** `identity` in `data.db` (`is_local = 1 AND data IS NOT NULL`) +- **Destination:** `StoredQualifiedIdentity` in DET's per-network k/v — see the design doc +- **Mapping:** `QualifiedIdentity::from_bytes(row.data)` → restore `status` from its column → + `AppContext::insert_local_qualified_identity(&qi, &wallet_link)`. The `data` BLOB carries + **all identity key material**; there is no second key table in v0.9.3. +- **Per-network split:** Yes — two-value filter (`mainnet` / legacy `dash`) +- **Gotchas:** The real risk is cross-version bincode compatibility of the `data` BLOB, not a + version byte. Every DET-side and dpp-side struct in the blob was verified unchanged between + v0.9.3 and HEAD; only the `bincode` rc.3 → 2.0.1 wire format is unproven, and a golden-blob + test closes it. Still the highest-risk table in the migration. - **Status:** DONE for new-install path — see commit `b14bf32c` (identities + tokens → - per-network k/v). Migration tool still needs to import legacy rows with the version-byte - contract correct. + per-network k/v). Migration tool still needs to import legacy rows. --- diff --git a/docs/ai-design/2026-07-13-legacy-identity-migration/design.md b/docs/ai-design/2026-07-13-legacy-identity-migration/design.md new file mode 100644 index 000000000..0f4e18e43 --- /dev/null +++ b/docs/ai-design/2026-07-13-legacy-identity-migration/design.md @@ -0,0 +1,446 @@ +# Legacy identity import (v0.9.3 → v1.0) + +**Status:** design, ready for implementation. +**Base:** `feat/legacy-identity-migration` @ `0fcb6e7e` (PR #882 tip). +**Scope:** carry the legacy `data.db` `identity` rows into the modern +`StoredQualifiedIdentity` k/v store during the cold-start migration. + +--- + +## 1. The gap + +The schema ladder preserves the v0.9.3 `identity` table intact — `v093_upgrade.rs` +asserts exactly that: `alias`, `wallet`, `wallet_index`, `identity_type` and +`network` all survive from schema v11 to current. Nothing then reads it. No +production code path imports those rows into the per-network k/v store that +`AppContext::load_local_qualified_identities` reads from, so a v0.9.3 user who +upgrades finds an empty Identities screen and an empty Masternodes screen. A +masternode owner loses the owner/voting/payout keys they had loaded and must +re-import them by hand. + +The `2026-05-28-migration-tool` notes already called `identity` "the highest-risk +table in the migration" and left it at "Migration tool still needs to import +legacy rows with the version-byte contract correct." This document settles what +that contract actually is, and it is not what the note assumed. + +--- + +## 2. What v0.9.3 actually persisted + +Source: `git show v0.9.3:src/database/identities.rs`, `…:src/database/initialization.rs`. + +```sql +CREATE TABLE identity ( + id BLOB PRIMARY KEY, -- 32-byte Identifier + data BLOB, -- QualifiedIdentity::to_bytes() (NULLABLE) + status INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL, -- 1 = user-owned, 0 = observed-only + alias TEXT, + info TEXT, -- never written by v0.9.3 + wallet BLOB, -- WalletSeedHash ([u8; 32]), nullable + wallet_index INTEGER, -- u32, NOT NULL iff wallet IS NOT NULL + identity_type TEXT, -- format!("{:?}") of IdentityType + network TEXT NOT NULL, + CHECK ((wallet IS NOT NULL AND wallet_index IS NOT NULL) + OR (wallet IS NULL AND wallet_index IS NULL)) +); +``` + +Three facts that decide the whole design: + +1. **`data` is the entire identity, keys included.** v0.9.3's + `insert_local_qualified_identity` writes `qualified_identity.to_bytes()` — + bincode of the full `QualifiedIdentity`, whose `private_keys: KeyStorage` + carries owner / voting / payout private-key material as `Clear`, + `AlwaysClear`, `Encrypted`, or `AtWalletDerivationPath`. There is **no second + table** holding identity keys in v0.9.3. Everything to migrate is in this one + BLOB plus five scalar columns. Key material is therefore *not* re-derivable + from the wallet seed in the general case: a masternode owner's owner/voting + key is typically an imported WIF stored as `Clear`, not an HD path. +2. **`data` is nullable, and `is_local` is not always 1.** v0.9.3's + `insert_identity_if_not_exists` writes observed (non-local) identities with + `is_local = 0` and a possibly-`NULL` blob. Every v0.9.3 read path filters + `WHERE is_local = 1 AND data IS NOT NULL`. The import must filter identically + — a non-local row is a cache entry, not user data. +3. **`identity_type` is `Debug`-formatted**, yielding exactly `"User"` / + `"Masternode"` / `"Evonode"` — byte-identical to the modern + `IdentityType::as_tag()`. **All three variants can appear.** The test's + masternode-only fixture is *not* representative: a v0.9.3 user with a DPNS + name has a `User` identity, and an evonode operator has `Evonode`. The import + must be type-agnostic (it is, if it round-trips the blob). + +--- + +## 3. The target shape + +`src/context/identity_db.rs`: + +```rust +struct StoredQualifiedIdentity { + qi_bytes: Vec, // QualifiedIdentity::to_bytes() + status: u8, + identity_type: String, // IdentityType::as_tag() + wallet_hash: Option<[u8; 32]>, + wallet_index: Option, +} +``` + +written at `DetScope::Identity(&id)` / key `det:identity:v1`, with the id also +registered in the Global roster `det:identity_index:v1` (there is no +cross-identity listing under `DetScope::Identity`, so an identity absent from +the roster is invisible to every load path). + +The production writer is `AppContext::insert_local_qualified_identity(&qi, &Some((seed_hash, index)))`. +It already does, in order: vault-first key extraction → blob encode → roster +insert → `DetKv::put`. **The import must call it and nothing lower.** + +--- + +## 4. The version-byte contract + +The 2026-05-28 note said the destination was upstream's `identities.entry_blob` +with a leading version byte "confirm the byte format with the +platform-wallet-storage author before implementing." That is **stale**. The +destination moved (commit `b14bf32c`) to DET's own per-network k/v. There are now +two byte layers, and neither needs a new author agreement: + +| Layer | Produced by | Format | +|---|---|---| +| **Outer** (k/v value) | `DetKv::put` — `src/wallet_backend/kv.rs:188` | `[ SCHEMA_VERSION (1B) = 1 ‖ bincode(StoredQualifiedIdentity) ]` | +| **Inner** (`qi_bytes`) | `QualifiedIdentity::to_bytes()` — `src/model/qualified_identity/mod.rs:510` | `bincode(QualifiedIdentity, config::standard())` — **no version byte** | + +The outer byte is prepended automatically and validated on read +(`KvAdapterError::SchemaVersion` on mismatch). The importer gets it for free by +going through `insert_local_qualified_identity`. **Hand-rolling the blob bytes is +the one way to get this wrong; the design forbids it.** + +The inner layer is where the real risk lives, and it is a *cross-version bincode +compatibility* question, not a version-byte question: + +### 4.1 Evidence that the legacy blob decodes on HEAD + +| Item | v0.9.3 | HEAD | Verdict | +|---|---|---|---| +| `QualifiedIdentity` manual `Encode` field order | `identity, associated_voter_identity, associated_operator_identity, associated_owner_key_id, identity_type, alias, private_keys, dpns_names` | identical | ✅ | +| `IdentityType` | `User, Masternode, Evonode` | identical | ✅ | +| `PrivateKeyTarget` | 3 variants, same order | identical | ✅ | +| `QualifiedIdentityPublicKey` | `{ identity_public_key, in_wallet_at_derivation_path }` | identical | ✅ | +| `KeyStorage` | `BTreeMap<(PrivateKeyTarget, KeyID), (QualifiedIdentityPublicKey, PrivateKeyData)>` | identical | ✅ | +| `PrivateKeyData` | `AlwaysClear, Clear, Encrypted, AtWalletDerivationPath` (0–3) | same 0–3, `InVault` **appended** at 4 | ✅ legacy discriminants unchanged | +| `WalletSeedHash` | `[u8; 32]` | identical | ✅ | +| dpp `IdentityV0` / `IdentityPublicKeyV0` fields (platform `29f7492` vs `44c20e3`) | `{id, public_keys, balance, revision}` / `{id, purpose, security_level, contract_bounds, key_type, read_only, data, disabled_at}` | identical — the inter-rev diff is serde attributes and added tests only | ✅ | +| dpp `Purpose` / `SecurityLevel` / `KeyType` / `ContractBounds` | — | no variant added or reordered | ✅ | +| bincode | `=2.0.0-rc.3` | `=2.0.1` | ⚠️ **unverified** | + +Everything structural checks out. The one open item is whether bincode's +`config::standard()` wire format is identical between `2.0.0-rc.3` and `2.0.1`. +It almost certainly is (varint + little-endian was frozen well before rc.3), but +"almost certainly" is not a contract for a blob that holds a masternode owner +key. **T-ID-06 (§9) closes it empirically with a golden blob.** Nothing else in +this plan depends on the answer; if the format *did* drift, the import would fail +loudly on decode (bincode errors, it does not silently produce garbage), the +sentinel would be withheld, and the legacy rows would still be there. + +--- + +## 5. Where the step plugs in + +A **sibling pass in `finish_unwire::run`**, after `drain_wallets` succeeds, under +its **own per-network sentinel**: + +``` +det:migration:identities::v1 +``` + +```rust +pub async fn run(app_context: &Arc) -> Result { + let app_data = migrate_app_data(app_context); // unchanged + let wallet_moved = drain_wallets(app_context).await?; // unchanged + let app_data = app_data?; + let identities = migrate_identities(app_context)?; // NEW — after the drain + let moved_data = wallet_moved || app_data.moved_data() || identities.moved_data(); + … +} +``` + +**Why after `drain_wallets`, not inside it.** The import needs three things the +drain produces: the wallet backend wired (`det_kv()` is `wallet_backend()?.kv()`), +the secret vault reachable (key material goes there), and `ctx.wallets` hydrated +(`register_migrated_wallets` calls `hydrate_context_wallets`) so that +`AtWalletDerivationPath` keys land against a wallet that actually exists. Running +it inside the drain, before registration, would import identities whose wallet +link points at nothing yet. + +**Why a new sentinel, not the drain's.** This is the same trap the app-data +sentinel was created to dodge, and the code says so at `migrate_app_data`: *"an +install that already completed the wallet drain under an earlier build (which had +no app-data import) still has those rows in `data.db`, and the wallet sentinel +would otherwise short-circuit the launch and strand them."* Every alpha/RC tester +who has already run the drain has `det:migration:finish_unwire::v1` written. +Reusing it would ship an identity importer that never runs for exactly the people +testing it. A third sentinel costs one constant. + +**Failure isolation.** A failing identity import must not withhold the wallet +sentinel — funds access is already restored and sentinel-guarded by the time this +runs. Conversely the identity sentinel is withheld on any failure so the next +launch retries. `MigrationStep::Identities` is added for the progress UI. + +--- + +## 6. Data mapping + +| Legacy column | Destination | Notes | +|---|---|---| +| `data` (BLOB) | `QualifiedIdentity::from_bytes` → `qi.` everything | Includes **all key material**. | +| `status` (u8) | `qi.status = IdentityStatus::from_u8(..)` | The encoder **skips** `status`, so a decoded blob carries the default `Unknown`. Restoring it from the column is mandatory or every migrated identity reads back as "unknown, refresh required". | +| `alias` | already inside `data` | Column is a denormalised copy. Prefer the blob; the column is a fallback if `qi.alias` is `None`. | +| `identity_type` | already inside `data` | Column is `format!("{:?}")` of the same value. `insert_local_qualified_identity` re-derives the wrapper's tag from `qi.identity_type`. Do not read the column. | +| `wallet` + `wallet_index` | `wallet_and_identity_id_info: Option<(WalletSeedHash, u32)>` | Both-or-neither (enforced by the legacy `CHECK`). | +| `network` | row filter `WHERE network IN (?1, ?2)` | Two-value filter via `mainnet_alias_for()` — a pre-v29 DB spells mainnet `dash`. | +| `is_local` | filter `= 1` | Non-local rows are observed-identity cache. Skip. | +| `info` | dropped | Never written by v0.9.3. | +| `top_up` join | already imported | `migrate_app_data` writes `det:top_ups:v1` under the same `DetScope::Identity`. Independent key; no interaction. | + +`qi.network` is set by the read path (`decode_stored_identity`), not stored. +`qi.associated_wallets` / `qi.secret_access` / `qi.top_ups` are runtime wiring, +rehydrated on load. None of them need migrating. + +--- + +## 7. Edge cases + +| Case | Behaviour | Rationale | +|---|---|---| +| **Row already in the k/v store** | Skip, count `skipped_existing`. Check `kv.get::(Identity(&id), IDENTITY_KEY).is_some()` **before** inserting. | `insert_local_qualified_identity` is INSERT-OR-REPLACE. Without the pre-check, a retry after a partial failure would overwrite an identity the user has since edited (alias, added key) with the stale legacy blob. Same class of bug as re-queuing a cast vote. | +| **Linked wallet failed to migrate / absent** | Import the identity anyway, **preserving `wallet_hash` + `wallet_index` verbatim**. Log at `warn`. | The link is what `load_local_qualified_identities_for_wallet` uses to re-attach the identity when the wallet is later restored or unlocked. Nulling it would orphan the identity permanently. A protected (locked) wallet is the *normal* case here — its seed is in the vault but it is not in `ctx.wallets` until the user unlocks. | +| **`data IS NULL` or `is_local = 0`** | Skip silently, do not count as failure. | Not user identity data; v0.9.3's own readers ignore these rows. | +| **`data` fails to decode** | Count `unreadable`, log at `warn` with the identity id, **withhold the sentinel**, publish a terminal warning state. Do **not** fail the pass. | Diverges from the scheduled-vote policy (which writes the sentinel anyway) on purpose: a corrupt vote row is unrecoverable, but an undecodable identity blob may be a *decoder* defect (bincode drift, §4.1) that a later build fixes. Withholding the sentinel keeps the retry door open at the cost of one cheap re-attempt per launch; the skip-if-present rule makes the retry a no-op for everything that already landed. Failing the pass instead would be wrong — it would gate nothing useful and shout at a user who cannot act. | +| **`id` not 32 bytes / `wallet` not 32 bytes** | Count `unreadable`, same as above. | Corruption. | +| **Identity type the fixture doesn't cover (`User`, `Evonode`)** | Handled with no extra code — the type lives in the blob and round-trips. | See §2.3. The *test* must cover it; the *code* need not branch on it. | +| **Second launch** | Sentinel short-circuits; nothing is read, nothing is written. | Mirrors `drain_wallets`. | +| **Legacy rows** | **Never deleted.** | Repo-wide migration rule: a migration that deletes its source can never be retried. | + +--- + +## 8. Security + +This step moves identity private keys. The repo rule (`CLAUDE.md`, DET Module +Placement Policy) is that all wallet/identity secret bytes enter and leave the +vault through the single `wallet_backend/secret_seam.rs` chokepoint. + +**The design satisfies this by not writing any secret-handling code at all.** +`AppContext::insert_local_qualified_identity` → +`encode_identity_blob_vault_first` → `IdentityKeyView::store_all` → +`SecretSeam`. The importer's only contact with key material is the +`QualifiedIdentity` value it holds between `from_bytes` and `insert_…`, which is +the same handling every existing load path already performs. + +Consequences the implementer must respect: + +- **Do not** construct `StoredQualifiedIdentity` directly (it is private to + `identity_db.rs` — keep it that way). +- **Do not** write `qi_bytes` from the legacy blob verbatim. It would persist + `Clear` / `AlwaysClear` plaintext keys into `det-app.sqlite`, bypassing the + vault — precisely the leak `encode_identity_blob_vault_first` exists to close. + The eager load-path migration would eventually repair it, but "eventually" is + not a security property. +- **Do not** log the blob, the decoded `qi`, or any `PrivateKeyData`. `Debug` is + already redacting on `StoredQualifiedIdentity` and `PrivateKeyData`; log the + hex identity id only. +- A `PrivateKeyData::Encrypted(_)` key (legacy per-identity password envelope) + round-trips untouched — `has_plaintext_for_vault` ignores it, so it is neither + vaulted nor decrypted. That is correct: the migration has no password. + +--- + +## 9. Implementation tasks + +Ordered; each is independently reviewable. + +- **T-ID-01 — `database/legacy_import.rs`: `read_identities`.** + `pub(crate) fn read_identities(conn: &Connection, network: Network) -> rusqlite::Result` + returning `{ identities: Vec, unreadable: u32 }` where + `LegacyIdentityRow { id: [u8; 32], qi: QualifiedIdentity, status: u8, wallet: Option<([u8; 32], u32)> }`. + SQL: `SELECT id, data, status, wallet, wallet_index FROM identity WHERE is_local = 1 AND data IS NOT NULL AND network IN (?1, ?2)`, + params `(network.to_string(), mainnet_alias_for(network))`. Missing table ⇒ + empty. Per-row decode failure ⇒ `unreadable += 1`, warn, continue. Mirrors + `read_scheduled_votes` exactly. + *Depends on:* nothing. + +- **T-ID-02 — `MigrationStep::Identities`** in `context/migration_status.rs`, + plus its progress-UI label. + *Depends on:* nothing. + +- **T-ID-03 — `finish_unwire::migrate_identities`.** Sentinel + `identities_sentinel_key_for(network)` → `det:migration:identities::v1`. + Pure body `migrate_identities_from_conn(conn, network, is_present, insert) -> Result` + with closure seams (matching `migrate_app_data_from_conn`) so it unit-tests + without an `AppContext`. Counters: `imported`, `skipped_existing`, + `unreadable`. Sentinel written **iff `unreadable == 0`**. + *Depends on:* T-ID-01, T-ID-02. + +- **T-ID-04 — Wire into `run()`** after `drain_wallets`, before the terminal + state. Add `MigrationError::IdentityImportFailed { … }` for hard k/v-write + failures and a `MigrationState::SucceededWithUnreadableIdentities { count }` + terminal variant (sibling of the existing votes variant), or fold both counts + into one warning state — implementer's call, but the user must be told *which* + domain failed, since the remedies differ (re-schedule a vote vs. re-import a key). + *Depends on:* T-ID-03. + +- **T-ID-05 — Detection gate.** Add `"identity"` to `LEGACY_TABLES`. Note this + gate belongs to `drain_wallets`; `migrate_identities` needs its own cheap + "table has rows" probe so an identity-only install (a masternode voter with no + HD wallet) is not skipped. + *Depends on:* T-ID-03. + +- **T-ID-06 — Golden-blob test (the bincode contract).** A checked-in hex + constant: a `QualifiedIdentity::to_bytes()` produced by the **real v0.9.3 + binary** (see §11), asserted to decode on HEAD into the expected identity, keys + and type. This is the only test that proves §4.1's one open row. Small, permanent, + zero build cost after generation. + *Depends on:* the Part-2 generator (§11). + +--- + +## 10. Test plan (`v093_upgrade.rs`) + +Extend the existing fixture; keep its mutation-tested shape. + +**Fixture changes** — replace the placeholder `data = vec![0u8; 16]` blob (junk, +decodes to nothing) with real blobs, and widen coverage past masternode-only: + +| Row | Type | `is_local` | Wallet link | Key shape | Locks | +|---|---|---|---|---|---| +| A | `Masternode` | 1 | unprotected wallet, idx 0 | `Clear` owner + `Clear` voting | the reported bug | +| B | `User` | 1 | unprotected wallet, idx 1 | `AtWalletDerivationPath` | non-masternode variant; wallet-derived key path | +| C | `Evonode` | 1 | **none** (`NULL`/`NULL`) | `Clear` | wallet-less identity (masternode loaded by ProTxHash) | +| D | `User` | **0** | — | — | observed-identity cache row must be skipped | +| E | `User` | 1 | protected wallet | `AtWalletDerivationPath` | identity on a **locked** wallet still imports, link preserved | +| F | any | 1 | — | `data = NULL` | null-blob row must be skipped, not counted as failure | + +**Assertions in `v093_install_upgrades_…`** (replacing the current +"the identity row survives the ladder" block, which asserts the *precondition* +this design consumes — keep it, then add the outcome): + +1. `ctx.load_local_qualified_identities()` returns exactly A, B, C, E — not D, not F. +2. A's `alias == "my-masternode"`, `identity_type == Masternode`, + `wallet_index == Some(0)`, `status` equals the legacy column value **(not + `Unknown`)** — the status-restore contract from §6. +3. A's `masternode_key_presence()` reports `owner` and `voting` — the user's keys + came across, which is the whole point. +4. `ctx.load_local_masternode_identities()` returns A and C; + `load_local_user_identities()` returns B and E — the type-filtered views the + Masternodes and Identities screens actually call. +5. **No plaintext key on disk**: read the raw `det:identity:v1` value for A and + assert its decoded `private_keys` contain no `Clear`/`AlwaysClear` — every key + is `InVault`. This is the §8 security contract, and it must be asserted from + the *stored bytes*, not the in-memory `qi`. +6. The vault holds A's keys: `IdentityKeyView::new(secret_store, A).scheme(target, key_id)` is not `Absent`. +7. E imports with `wallet_hash == Some(protected_seed_hash)` even though the + protected wallet is locked and absent from `ctx.wallets`. +8. Top-up history still resolves for A — i.e. the identity blob and the + already-migrated `det:top_ups:v1` entry share a scope without colliding. + +**Assertions in `second_launch_after_a_v093_upgrade_changes_nothing`:** + +9. Edit A's alias post-migration (`ctx.set_identity_alias`), re-run + `finish_unwire::run` ⇒ the alias survives. This is the skip-if-present rule + (§7) and it must go **RED** against a naive implementation that re-inserts + unconditionally. +10. Identity count is unchanged; `run()` returns `false`. +11. The legacy `identity` rows are still in `data.db` (count unchanged). + +**Negative test (own `#[test]`, on `migrate_identities_from_conn`):** a row whose +`data` is garbage ⇒ `unreadable == 1`, the readable rows still import, and the +sentinel is **not** written. + +--- + +## 11. Part 2 — the bincode / standalone-crate question + +### 11.1 Is bincode really the only blocker? + +**Yes.** Verified empirically. A standalone crate (`/data/tmp/v093-fixture-probe`, +own `Cargo.lock`, not a workspace member) depending only on + +```toml +dash-evo-tool = { git = "https://github.com/dashpay/dash-evo-tool", tag = "v0.9.3" } +rusqlite = "0.37.0" +libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } +``` + +resolves cleanly: 928 packages, `bincode 2.0.0-rc.3` + `bincode_derive 2.0.0-rc.3` +selected with no conflict. Outside the workspace there is no unification pressure, +so the rc.3-vs-2.0.1 clash simply does not arise. The only other snag is a +`links = "sqlite3"` collision if the scratch crate pins a different `rusqlite` +major than v0.9.3's — matching v0.9.3's `rusqlite 0.37` / `libsqlite3-sys 0.35` +resolves it. Nothing else objects. + +It also **builds**: `cargo check --lib -p dash-evo-tool` against that lockfile +finishes green in 5m47s (warm shared cargo cache; a fully cold one is longer). + +### 11.2 Can v0.9.3's write paths be driven from `pub` API? + +Partly, and the parts that matter are reachable: + +- ✅ `Database::new(&path)` + `Database::initialize(&path)` — `pub`, no + `AppContext`. Produces the genuine v0.9.3 DDL and `database_version = 11`. +- ✅ `QualifiedIdentity { … }.to_bytes()` — `pub`, no `AppContext`. **This is the + only thing genuinely worth extracting**: it is the one artefact the current + fixture cannot honestly forge, and the one that closes §4.1. +- ✅ `model::wallet::encryption::encrypt_message` — `pub`. (Already used verbatim + by the current fixture from HEAD's own code, and byte-identical.) +- ❌ `Database::insert_local_qualified_identity(&self, qi, wallet_info, app_context: &AppContext)` + takes an `AppContext`, and v0.9.3's `AppContext::new` loads a `Config` from the + environment, builds an SDK, loads system data contracts and spawns a + `TaskManager`. Driving it is possible but heavy and fragile. + +The `AppContext` requirement is **avoidable and should be avoided**: the generator +does not need v0.9.3's row-writing SQL, only its *schema* and its *blob encoder*. +Rows go in with plain `rusqlite` — which is exactly what `v093_upgrade.rs` already +does, and which is already verified against `git show v0.9.3:`. + +### 11.3 Verdict — **qualified yes, scoped down** + +**Do not** build a fixture-regenerating tool that produces a whole v0.9.3 +`.sqlite` file, checked in or regenerated on demand. That is the rabbit hole: +the existing hand-rolled fixture is already source-verified line-by-line against +`git show v0.9.3:src/database/`, it reads clearly in the test, and a binary +`.sqlite` blob in the repo is opaque, unreviewable, and rots. It would replace a +good artefact with a worse one. + +**Do** build a one-shot, throwaway generator whose only output is a **golden hex +blob**: a real v0.9.3 `QualifiedIdentity::to_bytes()` for a masternode identity +with owner + voting `Clear` keys, printed as hex, pasted into `v093_upgrade.rs` +as a `const` and asserted to decode on HEAD (**T-ID-06**). That converts §4.1's +one unverified row — "bincode rc.3 and 2.0.1 agree on the wire format" — from an +assumption into a test, which is the single highest-value thing this whole +investigation can buy. The generator itself is scratch; it is never checked in, +never built in CI, and never maintained. Document how to regenerate it in a +comment above the constant. + +**Effort:** + +| | | +|---|---| +| Scratch crate + `main.rs` (construct `QualifiedIdentity`, print hex) | ~20 min | +| Build of the v0.9.3 dep graph (928 crates) — **measured**, warm cache | ~6 min wall clock, unattended | +| Paste constant + write the decode assertion | ~15 min | +| **Total attended** | **< 1 hour** | + +The build is long but hands-off, one-time, and off the critical path. The +artefact it produces is 3 lines in a test file with no ongoing cost. That is a +good trade. The full-`.sqlite`-fixture version of the same idea is not. + +--- + +## 12. Candy tally + +Confirmed defects and design gaps surfaced by this investigation: + +| Severity | Count | Items | +|---|---|---| +| **Critical** | 1 | Legacy identities (and all their key material) are silently dropped on the v0.9.3 → v1.0 upgrade. | +| **High** | 2 | Reusing the `finish_unwire` sentinel would strand every install that already ran the drain. Unconditional re-insert on retry would overwrite user edits with stale legacy blobs. | +| **Medium** | 3 | The `identity` `status` column is not in the bincode blob and would silently read back as `Unknown`. `identity` is absent from `LEGACY_TABLES`, so an identity-only install never trips detection. The rc.3-vs-2.0.1 bincode wire contract is unverified. | +| **Low** | 2 | The 2026-05-28 "version-byte contract" note is stale (destination moved to DET k/v). The test fixture's masternode-only, junk-blob identity row is not representative of the real column shape. | From ff05c3a2d5e8b4b72ab2a86fc6bc6e3ba1239b50 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:22:10 +0000 Subject: [PATCH 22/30] fix(dashpay): stop the Contacts tab losing loads, clicks and accepted accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/backend_task/dashpay.rs | 7 +- src/backend_task/dashpay/contact_info.rs | 254 ++++++++++++++++++- src/backend_task/dashpay/contacts.rs | 5 +- src/model/dashpay.rs | 26 ++ src/ui/dashpay/contact_details.rs | 5 +- src/ui/identity/contacts.rs | 299 +++++++++++++++++++---- src/ui/identity/hub_screen.rs | 10 +- src/ui/identity/request_card.rs | 106 ++++++-- src/ui/state/contacts_view.rs | 96 +++++++- tests/backend-e2e/dashpay_tasks.rs | 3 +- 10 files changed, 733 insertions(+), 78 deletions(-) diff --git a/src/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index debb92c18..bc0ee2cdd 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -19,6 +19,7 @@ pub mod validation; pub use contacts::ContactData; +use crate::model::dashpay::AcceptedAccounts; use crate::model::qualified_identity::QualifiedIdentity; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; @@ -120,7 +121,11 @@ pub enum DashPayTask { nickname: Option, note: Option, is_hidden: bool, - accepted_accounts: Vec, + /// The write replaces the whole `contactInfo` document, so a caller that + /// only flips `is_hidden` or edits a nickname must say + /// [`AcceptedAccounts::Preserve`] — otherwise the accounts the user + /// accepted are erased. + accepted_accounts: AcceptedAccounts, }, /// Register DashPay receiving addresses for incoming payment detection RegisterDashPayAddresses { diff --git a/src/backend_task/dashpay/contact_info.rs b/src/backend_task/dashpay/contact_info.rs index 974458af7..f895b7641 100644 --- a/src/backend_task/dashpay/contact_info.rs +++ b/src/backend_task/dashpay/contact_info.rs @@ -2,6 +2,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::dashpay::errors::DashPayError; use crate::backend_task::error::TaskError; use crate::context::AppContext; +use crate::model::dashpay::AcceptedAccounts; use crate::model::qualified_identity::QualifiedIdentity; use aes_gcm::aes::Aes256; use aes_gcm::aes::cipher::{BlockEncrypt, KeyInit}; @@ -115,6 +116,82 @@ impl ContactInfoPrivateData { Ok(bytes) } + + /// Parse the plaintext produced by [`serialize`](Self::serialize). + /// + /// Returns `None` when `bytes` is truncated mid-field — a document written + /// by another client in a format this one cannot read. Trailing padding is + /// ignored: every field is length-prefixed, so parsing stops at the last + /// declared account. + pub fn deserialize(bytes: &[u8]) -> Option { + let version = u32::from_le_bytes(bytes.get(..4)?.try_into().ok()?); + let mut pos = 4; + + let take_string = |pos: &mut usize| -> Option> { + let len = *bytes.get(*pos)? as usize; + *pos += 1; + let raw = bytes.get(*pos..*pos + len)?; + *pos += len; + Some(if len == 0 { + None + } else { + String::from_utf8(raw.to_vec()).ok() + }) + }; + let alias_name = take_string(&mut pos)?; + let note = take_string(&mut pos)?; + + let display_hidden = *bytes.get(pos)? != 0; + pos += 1; + + let count = *bytes.get(pos)? as usize; + pos += 1; + let accepted_accounts = (0..count) + .map(|_| { + let raw = bytes.get(pos..pos + 4)?; + pos += 4; + Some(u32::from_le_bytes(raw.try_into().ok()?)) + }) + .collect::>>()?; + + Some(Self { + version, + alias_name, + note, + display_hidden, + accepted_accounts, + }) + } +} + +/// The accounts a `contactInfo` write should store, honouring the caller's +/// [`AcceptedAccounts`] choice against the document already on Platform. +/// +/// [`AcceptedAccounts::Preserve`] reads the stored list back out of the existing +/// document's encrypted `privateData`. A document that is absent, unreadable, or +/// written in an unknown format yields an empty list: this is a brand-new +/// contact, or one whose accounts this client could never have shown the user +/// anyway — neither is a reason to fail the unhide or rename the user asked for. +fn resolve_accepted_accounts( + requested: AcceptedAccounts, + existing: Option<&Document>, + private_data_key: &[u8; 32], +) -> Vec { + match requested { + AcceptedAccounts::Replace(accounts) => accounts, + AcceptedAccounts::Preserve => { + let Some(Value::Bytes(encrypted)) = + existing.and_then(|doc| doc.properties().get("privateData")) + else { + return Vec::new(); + }; + super::contacts::decrypt_private_data(encrypted, private_data_key) + .ok() + .and_then(|plaintext| ContactInfoPrivateData::deserialize(&plaintext)) + .map(|data| data.accepted_accounts) + .unwrap_or_default() + } + } } /// Derive the DIP-0015 contactInfo encryption keys for `identity`, fetching @@ -244,6 +321,20 @@ fn encrypt_private_data(data: &[u8], key: &[u8; 32]) -> Result, String> Ok(result) } +/// Write the `contactInfo` document for `contact_user_id`, creating it when the +/// identity has none yet and replacing it otherwise. +/// +/// The document is written whole, so `accepted_accounts` decides what happens to +/// the accounts already stored: pass [`AcceptedAccounts::Preserve`] to keep them +/// (the right choice for a caller that only flips `display_hidden` or edits a +/// nickname), or a `Vec` — which converts to +/// [`AcceptedAccounts::Replace`] — to overwrite the list outright. +/// +/// # Errors +/// +/// Fails when the contact's encryption keys cannot be derived, when the +/// encrypted fields exceed the DashPay contract's size limits, when the identity +/// has no usable authentication key, or when the state transition is rejected. #[allow(clippy::too_many_arguments)] pub async fn create_or_update_contact_info( app_context: &Arc, @@ -253,7 +344,7 @@ pub async fn create_or_update_contact_info( nickname: Option, note: Option, display_hidden: bool, - accepted_accounts: Vec, + accepted_accounts: impl Into, ) -> Result { let dashpay_contract = app_context.dashpay_contract.clone(); let identity_id = identity.identity.id(); @@ -344,7 +435,11 @@ pub async fn create_or_update_contact_info( private_data.alias_name = nickname; private_data.note = note; private_data.display_hidden = display_hidden; - private_data.accepted_accounts = accepted_accounts; + private_data.accepted_accounts = resolve_accepted_accounts( + accepted_accounts.into(), + found_existing_doc.as_ref(), + &private_data_key, + ); // Encrypt private data let encrypted_private_data = @@ -501,3 +596,158 @@ pub async fn create_or_update_contact_info( contact_user_id, )) } + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [7u8; 32]; + const OTHER_KEY: [u8; 32] = [9u8; 32]; + + fn id(byte: u8) -> Identifier { + Identifier::from_bytes(&[byte; 32]).expect("32-byte identifier") + } + + /// A stored `contactInfo` document whose `privateData` holds `accounts`, + /// encrypted exactly the way [`create_or_update_contact_info`] writes it. + fn stored_contact_info(accounts: Vec, key: &[u8; 32]) -> Document { + let private_data = ContactInfoPrivateData { + version: 0, + alias_name: Some("Bao".to_string()), + note: None, + display_hidden: true, + accepted_accounts: accounts, + }; + let encrypted = encrypt_private_data(&private_data.serialize().expect("serialize"), key) + .expect("encrypt"); + + let mut properties = BTreeMap::new(); + properties.insert("privateData".to_string(), Value::Bytes(encrypted)); + DppDocument::V0(DocumentV0 { + id: id(1), + owner_id: id(2), + creator_id: None, + properties, + revision: Some(1), + created_at: None, + updated_at: None, + transferred_at: None, + created_at_block_height: None, + updated_at_block_height: None, + transferred_at_block_height: None, + created_at_core_block_height: None, + updated_at_core_block_height: None, + transferred_at_core_block_height: None, + }) + } + + #[test] + fn private_data_round_trips_every_accepted_account() { + let original = ContactInfoPrivateData { + version: 0, + alias_name: Some("Bao".to_string()), + note: Some("Met at the meetup".to_string()), + display_hidden: true, + accepted_accounts: vec![0, 3, 17], + }; + + let parsed = ContactInfoPrivateData::deserialize(&original.serialize().expect("serialize")) + .expect("a document this client wrote must parse back"); + + assert_eq!(parsed.alias_name.as_deref(), Some("Bao")); + assert_eq!(parsed.note.as_deref(), Some("Met at the meetup")); + assert!(parsed.display_hidden); + assert_eq!( + parsed.accepted_accounts, + vec![0, 3, 17], + "every accepted account must survive the round trip, not just the first" + ); + } + + #[test] + fn private_data_round_trips_through_the_minimum_size_padding() { + // An empty private data is padded up to the contract's minimum size — + // the padding must not be mistaken for a field. + let original = ContactInfoPrivateData::new(); + let parsed = ContactInfoPrivateData::deserialize(&original.serialize().expect("serialize")) + .expect("padded plaintext must parse"); + + assert_eq!(parsed.alias_name, None); + assert_eq!(parsed.note, None); + assert!(!parsed.display_hidden); + assert!(parsed.accepted_accounts.is_empty()); + } + + #[test] + fn truncated_private_data_is_not_parsed_as_a_shorter_list() { + let bytes = ContactInfoPrivateData { + version: 0, + alias_name: None, + note: None, + display_hidden: false, + accepted_accounts: vec![1, 2, 3], + } + .serialize() + .expect("serialize"); + + // Chop the final account in half: the list declares three, so a parser + // that returned the two it could read would silently drop an account. + assert!( + ContactInfoPrivateData::deserialize(&bytes[..bytes.len() - 2]).is_none(), + "a truncated account list must not parse as a shorter one" + ); + } + + #[test] + fn preserve_keeps_every_account_stored_on_the_existing_document() { + let existing = stored_contact_info(vec![0, 4, 9], &KEY); + + assert_eq!( + resolve_accepted_accounts(AcceptedAccounts::Preserve, Some(&existing), &KEY), + vec![0, 4, 9], + "preserving must return the whole stored list, not the first entry" + ); + } + + #[test] + fn replace_overwrites_whatever_the_document_stored() { + let existing = stored_contact_info(vec![0, 4, 9], &KEY); + + assert_eq!( + resolve_accepted_accounts(AcceptedAccounts::Replace(vec![2]), Some(&existing), &KEY), + vec![2], + "a caller that supplies a list owns it outright" + ); + assert!( + resolve_accepted_accounts(AcceptedAccounts::Replace(vec![]), Some(&existing), &KEY) + .is_empty(), + "an explicit empty list clears the stored accounts" + ); + } + + #[test] + fn preserving_a_contact_with_no_stored_document_yields_no_accounts() { + assert!( + resolve_accepted_accounts(AcceptedAccounts::Preserve, None, &KEY).is_empty(), + "a first-ever contactInfo has nothing to preserve" + ); + } + + #[test] + fn unreadable_private_data_preserves_nothing_instead_of_failing_the_write() { + let existing = stored_contact_info(vec![0, 4, 9], &OTHER_KEY); + + assert!( + resolve_accepted_accounts(AcceptedAccounts::Preserve, Some(&existing), &KEY).is_empty(), + "a privateData blob this client cannot decrypt must not block the write" + ); + } + + #[test] + fn a_bare_account_list_is_a_replacement() { + assert_eq!( + AcceptedAccounts::from(vec![1, 2]), + AcceptedAccounts::Replace(vec![1, 2]) + ); + } +} diff --git a/src/backend_task/dashpay/contacts.rs b/src/backend_task/dashpay/contacts.rs index f6e8e8f71..23bf8aa5b 100644 --- a/src/backend_task/dashpay/contacts.rs +++ b/src/backend_task/dashpay/contacts.rs @@ -108,7 +108,10 @@ fn decrypt_to_user_id(encrypted: &[u8], key: &[u8; 32]) -> Result<[u8; 32], Stri } // Helper function to decrypt private data using AES-256-CBC -fn decrypt_private_data(encrypted_data: &[u8], key: &[u8; 32]) -> Result, String> { +pub(super) fn decrypt_private_data( + encrypted_data: &[u8], + key: &[u8; 32], +) -> Result, String> { use cbc::cipher::BlockDecryptMut; use cbc::cipher::KeyIvInit; use cbc::cipher::block_padding::Pkcs7; diff --git a/src/model/dashpay.rs b/src/model/dashpay.rs index 3f5f8a9a5..94e6a4603 100644 --- a/src/model/dashpay.rs +++ b/src/model/dashpay.rs @@ -33,6 +33,32 @@ pub struct StoredProfile { pub updated_at: i64, } +/// What a `contactInfo` write does to the contact's accepted-accounts list. +/// +/// A `contactInfo` document is always written whole, so every write decides the +/// fate of the accounts the user has already accepted. A caller with no opinion +/// on them — unhiding a contact, renaming one — says [`Preserve`] and keeps the +/// stored list; only a caller that owns the list says [`Replace`]. +/// +/// [`Preserve`]: AcceptedAccounts::Preserve +/// [`Replace`]: AcceptedAccounts::Replace +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum AcceptedAccounts { + /// Keep the accounts already stored in the contact's `contactInfo`. + #[default] + Preserve, + /// Overwrite the stored accounts with exactly these. + Replace(Vec), +} + +impl From> for AcceptedAccounts { + /// A bare account list is a full overwrite — the caller supplied the whole + /// list, so it owns it. + fn from(accounts: Vec) -> Self { + Self::Replace(accounts) + } +} + /// Relationship state of a DashPay contact. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index cbaf2d31a..6c587f7d4 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -2,6 +2,7 @@ use crate::app::AppAction; use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; +use crate::model::dashpay::AcceptedAccounts; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::components::MessageBanner; use crate::ui::components::dashpay_subscreen_chooser_panel::add_dashpay_subscreen_chooser_panel; @@ -213,7 +214,9 @@ impl ContactDetailsScreen { Some(self.edit_note.clone()) }, is_hidden: self.edit_hidden, - accepted_accounts: vec![], + // This form edits the nickname, note, and hidden flag — it has + // no say over which accounts the user accepted. + accepted_accounts: AcceptedAccounts::Preserve, }, ))) } diff --git a/src/ui/identity/contacts.rs b/src/ui/identity/contacts.rs index 7f527626d..f967749f7 100644 --- a/src/ui/identity/contacts.rs +++ b/src/ui/identity/contacts.rs @@ -16,10 +16,11 @@ use super::request_card::{RequestAction, RequestCard}; use super::social_profile_gate_card::SocialProfileGateCard; -use crate::app::AppAction; +use crate::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; use crate::backend_task::dashpay::{ContactData, DashPayTask}; use crate::context::AppContext; +use crate::model::dashpay::AcceptedAccounts; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; use crate::ui::identity::identity_pill::shorten_id; @@ -123,26 +124,80 @@ pub fn contacts_button_kind(button: ContactsButton) -> ContactsButtonKind { } } -/// Render one request row and dispatch whatever the user clicked on it. +/// Render one request row and report which request the user acted on. /// /// The received and sent sections differ only in the card they hand in — the -/// row itself (render, spacing, action dispatch) is the same on both, so it -/// lives here once. +/// row itself (render, spacing, click reporting) is the same on both, so it +/// lives here once. Dispatch is [`dispatch_request`]'s job, which needs the +/// state this loop only reads. fn request_row( ui: &mut Ui, card: RequestCard, entry: &ContactRequestEntry, - identity: &QualifiedIdentity, -) -> AppAction { +) -> Option<(Identifier, RequestAction)> { let response = card.show(ui); ui.add_space(4.0); - match response.action() { - Some(request_action) => AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - request_task(request_action, identity.clone(), entry.request_id), - ))), - None => AppAction::None, + response.action().map(|action| (entry.request_id, action)) +} + +/// Pair each request with whether its action is already running, so the row loop +/// no longer needs the state it would otherwise have to hold borrowed. +fn request_rows<'a>( + entries: &'a [ContactRequestEntry], + state: &ContactsState, +) -> Vec<(&'a ContactRequestEntry, bool)> { + entries + .iter() + .map(|entry| (entry, state.is_in_flight(&entry.request_id))) + .collect() +} + +/// Dispatch the backend task for the request row the user clicked, if any. +/// +/// Accept, Decline, and Cancel each sign and pay for a state transition, so the +/// request is marked in flight and a further click on it dispatches nothing +/// until its result lands. The row's buttons are disabled meanwhile; this guard +/// is what makes that true rather than merely visible. +fn dispatch_request( + state: &mut ContactsState, + identity: &QualifiedIdentity, + clicked: Option<(Identifier, RequestAction)>, +) -> AppAction { + let Some((request_id, action)) = clicked else { + return AppAction::None; + }; + if !state.begin_request(request_id) { + return AppAction::None; + } + AppAction::BackendTask(BackendTask::DashPayTask(Box::new(request_task( + action, + identity.clone(), + request_id, + )))) +} + +/// One-shot hydration of the tab: the contact list and the request lists. +/// +/// Both loads travel as a single [`AppAction::BackendTasks`]. Two separate +/// `AppAction::BackendTask`s would not: `AppAction`'s `|=` is last-writer-wins, +/// so the first load would be dropped on the floor and the active section would +/// never fill. +fn load_action(identity: &QualifiedIdentity, state: &mut ContactsState) -> AppAction { + if !state.claim_load() { + return AppAction::None; } + AppAction::BackendTasks( + vec![ + BackendTask::DashPayTask(Box::new(DashPayTask::LoadContacts { + identity: identity.clone(), + })), + BackendTask::DashPayTask(Box::new(DashPayTask::LoadContactRequests { + identity: identity.clone(), + })), + ], + BackendTasksExecutionMode::Concurrent, + ) } /// Backend task for a request-card action. Accept and Decline act on a received @@ -171,8 +226,10 @@ pub fn request_task( /// Backend task that makes a hidden contact visible again. /// /// Unhiding is a `contactInfo` broadcast with `display_hidden` cleared — the -/// same document the decline / cancel paths set it on. The contact's nickname -/// and note ride along so restoring visibility does not wipe them. +/// same document the decline / cancel paths set it on. The whole document is +/// rewritten, so everything unhiding has no opinion about rides along untouched: +/// the nickname and note are carried over, and the accepted accounts are +/// preserved rather than replaced with an empty list. pub fn unhide_task(identity: QualifiedIdentity, contact: &ContactData) -> DashPayTask { DashPayTask::UpdateContactInfo { identity, @@ -180,7 +237,7 @@ pub fn unhide_task(identity: QualifiedIdentity, contact: &ContactData) -> DashPa nickname: contact.nickname.clone(), note: contact.note.clone(), is_hidden: false, - accepted_accounts: Vec::new(), + accepted_accounts: AcceptedAccounts::Preserve, } } @@ -316,19 +373,14 @@ fn render_populated( // Fire LoadContacts + LoadContactRequests once per tab entry. The hub // resets the guard in `refresh_on_arrival()` so a tab switch or explicit - // refresh triggers another load. Both tasks dispatch together so all three - // sections hydrate in a single round-trip. - if state.claim_load() { - action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - DashPayTask::LoadContacts { - identity: identity.clone(), - }, - ))); - action |= AppAction::BackendTask(BackendTask::DashPayTask(Box::new( - DashPayTask::LoadContactRequests { - identity: identity.clone(), - }, - ))); + // refresh triggers another load. + // + // Only when nothing else claimed this frame: `|=` is last-writer-wins, so a + // load dispatched alongside a click would swallow one of the two. The load + // guard is untouched until it actually dispatches, so it simply goes out on + // the next paint. + if matches!(action, AppAction::None) { + action = load_action(identity, state); } action @@ -339,36 +391,39 @@ fn render_populated( fn received_section( ui: &mut Ui, identity: &QualifiedIdentity, - state: &ContactsState, + state: &mut ContactsState, dark_mode: bool, ) -> AppAction { - let mut action = AppAction::None; ui.add_space(12.0); - let incoming = state.incoming(); - let heading = if incoming.is_empty() { + let rows = request_rows(state.incoming(), state); + let heading = if rows.is_empty() { RECEIVED_HEADING.to_string() } else { - format!("{RECEIVED_HEADING} · {}", incoming.len()) + format!("{RECEIVED_HEADING} · {}", rows.len()) }; + // Collected inside the row loop and acted on after it, so dispatching can + // take `state` mutably once the loop's read borrow has ended. + let mut clicked = None; section_card(ui, dark_mode, &heading, |ui| { - if incoming.is_empty() { + if rows.is_empty() { ui.label(RichText::new(NO_RECEIVED_EMPTY).color(DashColors::text_secondary(dark_mode))); return; } - for entry in incoming { + for (entry, busy) in rows { let handle = entry.counterpart_id.to_string(Encoding::Base58); let card = RequestCard::received( shorten_id(&handle), &handle, entry.relative_time.as_deref().unwrap_or(""), - ); - action |= request_row(ui, card, entry, identity); + ) + .with_busy(busy); + clicked = request_row(ui, card, entry).or(clicked); } }); - action + dispatch_request(state, identity, clicked) } /// Active contacts — searchable list of established contacts, each row offering @@ -518,21 +573,21 @@ fn hidden_section( fn sent_section( ui: &mut Ui, identity: &QualifiedIdentity, - state: &ContactsState, + state: &mut ContactsState, dark_mode: bool, ) -> AppAction { - let mut action = AppAction::None; ui.add_space(12.0); - let outgoing = state.outgoing(); - let heading = if outgoing.is_empty() { + let rows = request_rows(state.outgoing(), state); + let heading = if rows.is_empty() { SENT_HEADING.to_string() } else { - format!("{SENT_HEADING} · {}", outgoing.len()) + format!("{SENT_HEADING} · {}", rows.len()) }; + let mut clicked = None; section_card(ui, dark_mode, &heading, |ui| { - if outgoing.is_empty() { + if rows.is_empty() { ui.label(RichText::new(NO_SENT_EMPTY).color(DashColors::text_secondary(dark_mode))); return; } @@ -544,14 +599,14 @@ fn sent_section( ); ui.add_space(8.0); - for entry in outgoing { + for (entry, busy) in rows { let handle = entry.counterpart_id.to_string(Encoding::Base58); - let card = RequestCard::sent(shorten_id(&handle), &handle); - action |= request_row(ui, card, entry, identity); + let card = RequestCard::sent(shorten_id(&handle), &handle).with_busy(busy); + clicked = request_row(ui, card, entry).or(clicked); } }); - action + dispatch_request(state, identity, clicked) } /// Header row: title on the left, three action buttons right-aligned. @@ -929,6 +984,156 @@ mod tests { } } + #[test] + fn unhide_leaves_the_accepted_accounts_alone() { + // The write replaces the whole contactInfo document. Unhiding says + // nothing about which accounts the user accepted, so it must not + // volunteer an empty list — that would erase every one of them. + let task = unhide_task(qualified_identity(id(1)), &hidden_contact(None, None)); + match task { + DashPayTask::UpdateContactInfo { + accepted_accounts, .. + } => assert_eq!( + accepted_accounts, + AcceptedAccounts::Preserve, + "unhiding must preserve the contact's accepted accounts, not overwrite them" + ), + other => panic!("expected UpdateContactInfo, got {other:?}"), + } + } + + // --------------------------------------------------------------- + // Hydration — both loads must survive the trip to `AppState`. + // `AppAction`'s `|=` is last-writer-wins, so two separate + // `BackendTask` actions in one frame mean the first is silently lost. + // --------------------------------------------------------------- + + #[test] + fn hydration_dispatches_both_loads_in_one_action() { + let identity = qualified_identity(id(1)); + let mut state = ContactsState::default(); + + let AppAction::BackendTasks(tasks, mode) = load_action(&identity, &mut state) else { + panic!("hydration must dispatch its loads as one multi-task action"); + }; + + assert_eq!(mode, BackendTasksExecutionMode::Concurrent); + assert!( + tasks.iter().any(|t| matches!( + t, + BackendTask::DashPayTask(task) if matches!(**task, DashPayTask::LoadContacts { .. }) + )), + "the contact list must be loaded — without it the active section stays empty" + ); + assert!( + tasks.iter().any(|t| matches!( + t, + BackendTask::DashPayTask(task) + if matches!(**task, DashPayTask::LoadContactRequests { .. }) + )), + "the request lists must be loaded" + ); + } + + #[test] + fn hydration_fires_once_per_tab_entry() { + let identity = qualified_identity(id(1)); + let mut state = ContactsState::default(); + + assert_ne!(load_action(&identity, &mut state), AppAction::None); + assert_eq!( + load_action(&identity, &mut state), + AppAction::None, + "a second paint must not re-dispatch the loads" + ); + } + + // --------------------------------------------------------------- + // In-flight guard — Accept / Decline / Cancel each sign and pay for a + // state transition, so a second click while the first is running must + // not buy a second one. + // --------------------------------------------------------------- + + #[test] + fn a_second_click_while_the_request_is_in_flight_dispatches_nothing() { + let identity = qualified_identity(id(1)); + let mut state = ContactsState::default(); + let clicked = Some((id(2), RequestAction::Accepted)); + + assert_ne!( + dispatch_request(&mut state, &identity, clicked), + AppAction::None, + "the first click must dispatch" + ); + assert!( + state.is_in_flight(&id(2)), + "the row must be marked in flight" + ); + assert_eq!( + dispatch_request(&mut state, &identity, clicked), + AppAction::None, + "a second click must not pay for a second state transition" + ); + } + + #[test] + fn another_request_stays_clickable_while_one_is_in_flight() { + let identity = qualified_identity(id(1)); + let mut state = ContactsState::default(); + + dispatch_request( + &mut state, + &identity, + Some((id(2), RequestAction::Accepted)), + ); + + assert_ne!( + dispatch_request( + &mut state, + &identity, + Some((id(3), RequestAction::Declined)) + ), + AppAction::None, + "the guard is per request — an unrelated row must still act" + ); + } + + #[test] + fn a_failed_request_becomes_clickable_again() { + let identity = qualified_identity(id(1)); + let mut state = ContactsState::default(); + let clicked = Some((id(2), RequestAction::Cancelled)); + + dispatch_request(&mut state, &identity, clicked); + // What the hub does when a task fails: it has no request ID to key on, + // so it releases every guard rather than stranding a row. + state.clear_in_flight(); + + assert_ne!( + dispatch_request(&mut state, &identity, clicked), + AppAction::None, + "a failed action must leave the row actionable, not stuck forever" + ); + } + + #[test] + fn a_resolved_request_releases_its_guard() { + let identity = qualified_identity(id(1)); + let mut state = ContactsState::default(); + + dispatch_request( + &mut state, + &identity, + Some((id(2), RequestAction::Accepted)), + ); + state.remove_request(&id(2)); + + assert!( + !state.is_in_flight(&id(2)), + "a resolved request must not hold its guard" + ); + } + #[test] fn every_request_action_maps_to_a_distinct_task() { let tasks: Vec = [ diff --git a/src/ui/identity/hub_screen.rs b/src/ui/identity/hub_screen.rs index 655ec5c3d..2cbd16d2c 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -391,8 +391,11 @@ impl ScreenLike for IdentityHubScreen { ); } // The counterpart answered while the row was on screen. Nothing to - // withdraw or accept — reload into the truth. + // withdraw or accept — reload into the truth. The result names the + // contact, not the request, so every request guard is released and + // the reloaded lists decide what is still actionable. BackendTaskSuccessResult::DashPayContactAlreadyEstablished(_) => { + self.contacts_state.clear_in_flight(); self.contacts_state.invalidate(); MessageBanner::set_global( self.app_context.egui_ctx(), @@ -405,6 +408,11 @@ impl ScreenLike for IdentityHubScreen { } fn display_task_error(&mut self, _error: &TaskError) -> bool { + // A failed Accept / Decline / Cancel must leave its row clickable again. + // The error carries no request ID, so every guard is released: the worst + // case is a row the user can retry, against a row stuck forever. + self.contacts_state.clear_in_flight(); + // Clear any dangling pending_save so a failed UpdateProfile doesn't // leave a stale snapshot around. If a later DashPayProfileUpdated from // a different path (e.g. the legacy ProfileScreen) arrives it would diff --git a/src/ui/identity/request_card.rs b/src/ui/identity/request_card.rs index 40a1d66ef..a051d343f 100644 --- a/src/ui/identity/request_card.rs +++ b/src/ui/identity/request_card.rs @@ -133,6 +133,10 @@ pub struct RequestCard { /// Optional identifier propagated to the response. Useful for routing /// clicks without maintaining a parallel index on the caller side. id: Option, + /// Whether this request's action is already running. Disables the action + /// buttons, so the user is not invited to pay for a second state transition + /// while the first is still on its way. + busy: bool, } impl RequestCard { @@ -148,6 +152,7 @@ impl RequestCard { handle: handle.into(), relative_time: relative_time.into(), id: None, + busy: false, } } @@ -159,6 +164,7 @@ impl RequestCard { handle: handle.into(), relative_time: String::new(), id: None, + busy: false, } } @@ -168,6 +174,15 @@ impl RequestCard { self } + /// Disable the card's action buttons while its request is in flight. + /// + /// Accept, Decline, and Cancel each cost a real fee, so a card whose action + /// is already running must not take another click. + pub fn with_busy(mut self, busy: bool) -> Self { + self.busy = busy; + self + } + /// The variant (for tests and compositional callers). pub fn variant(&self) -> RequestCardVariant { self.variant @@ -231,31 +246,43 @@ impl RequestCard { // Right-aligned action cluster. ui.with_layout( eframe::egui::Layout::right_to_left(eframe::egui::Align::Center), - |ui| match self.variant { - RequestCardVariant::Received => { - if ui - .add(ComponentStyles::secondary_button(DECLINE_LABEL, dark_mode)) - .clicked() - { - response.declined = true; - } - ui.add_space(8.0); - if ui - .add(ComponentStyles::primary_button(ACCEPT_LABEL)) - .clicked() - { - response.accepted = true; + |ui| { + let actionable = !self.busy; + match self.variant { + RequestCardVariant::Received => { + if ui + .add_enabled( + actionable, + ComponentStyles::secondary_button(DECLINE_LABEL, dark_mode), + ) + .clicked() + { + response.declined = true; + } + ui.add_space(8.0); + if ui + .add_enabled( + actionable, + ComponentStyles::primary_button(ACCEPT_LABEL), + ) + .clicked() + { + response.accepted = true; + } } - } - RequestCardVariant::Sent => { - if ui - .add(ComponentStyles::secondary_button(CANCEL_LABEL, dark_mode)) - .clicked() - { - response.cancelled = true; + RequestCardVariant::Sent => { + if ui + .add_enabled( + actionable, + ComponentStyles::secondary_button(CANCEL_LABEL, dark_mode), + ) + .clicked() + { + response.cancelled = true; + } + ui.add_space(8.0); + paint_pending_pill(ui, dark_mode); } - ui.add_space(8.0); - paint_pending_pill(ui, dark_mode); } }, ); @@ -427,6 +454,39 @@ mod tests { assert!(r.id.is_none()); } + /// The click that reaches a card whose action is already running must not + /// produce a second action — the second Accept would sign, broadcast, and + /// pay for a second state transition. + #[test] + fn a_busy_card_does_not_act_on_a_further_click() { + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use std::cell::Cell; + + for busy in [false, true] { + let acted = Cell::new(false); + let mut harness = Harness::builder() + .with_size(eframe::egui::vec2(600.0, 200.0)) + .build_ui(|ui| { + let response = RequestCard::received("Alex Kim", "alex.dash", "2m ago") + .with_busy(busy) + .show(ui); + if response.action().is_some() { + acted.set(true); + } + }); + + harness.get_by_label(ACCEPT_LABEL).click(); + harness.run(); + + assert_eq!( + acted.get(), + !busy, + "a card with an action in flight must ignore the click, an idle one must take it" + ); + } + } + #[test] fn action_derives_from_booleans() { let mut r = RequestCardResponse::default(); diff --git a/src/ui/state/contacts_view.rs b/src/ui/state/contacts_view.rs index c45c2022f..dd19de22d 100644 --- a/src/ui/state/contacts_view.rs +++ b/src/ui/state/contacts_view.rs @@ -12,6 +12,7 @@ use crate::ui::identity::identity_pill::display_label; use dash_sdk::dpp::document::DocumentV0Getters; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::platform::{Document, Identifier}; +use std::collections::HashSet; /// A single cached contact-request entry, derived from a raw /// `DashPayContactRequests` result document. @@ -53,6 +54,11 @@ pub struct ContactsState { show_hidden: bool, /// Live search query bound to the Contacts search box. search: String, + /// Requests whose Accept / Decline / Cancel is already running, keyed by + /// request ID. Each of those actions is a signed, paid-for state transition, + /// so a row keeps its buttons disabled until its result lands — a second + /// click would buy a second transition. + in_flight: HashSet, } impl ContactsState { @@ -76,6 +82,7 @@ impl ContactsState { self.hidden.clear(); self.show_hidden = false; self.search.clear(); + self.in_flight.clear(); } /// Re-arm the load without clearing what is already on screen. Used after a @@ -192,10 +199,36 @@ impl ContactsState { /// Drop a resolved request (accepted, declined, or cancelled) from both /// lists so the row leaves the UI immediately, without waiting for the - /// authoritative reload to land. + /// authoritative reload to land. Also releases the request's in-flight + /// guard, since its action is now resolved. pub fn remove_request(&mut self, request_id: &Identifier) { self.incoming.retain(|e| e.request_id != *request_id); self.outgoing.retain(|e| e.request_id != *request_id); + self.in_flight.remove(request_id); + } + + /// Claim the in-flight slot for a request. `true` means the caller owns the + /// dispatch; `false` means an action for that request is already running and + /// the caller must not dispatch a second one. + pub fn begin_request(&mut self, request_id: Identifier) -> bool { + self.in_flight.insert(request_id) + } + + /// Whether an action for this request is already running. Drives the row's + /// disabled state, so the user sees why the buttons do not respond. + pub fn is_in_flight(&self, request_id: &Identifier) -> bool { + self.in_flight.contains(request_id) + } + + /// Release every in-flight guard. + /// + /// Success releases a single request by ID through [`remove_request`]. A + /// failure carries no request ID, so the hub releases all of them: a row the + /// user can click again is right, a row stuck forever is not. + /// + /// [`remove_request`]: Self::remove_request + pub fn clear_in_flight(&mut self) { + self.in_flight.clear(); } } @@ -535,6 +568,67 @@ mod tests { assert!(state.outgoing().is_empty(), "cancelled row must leave"); } + #[test] + fn a_request_is_in_flight_only_once() { + let mut state = ContactsState::default(); + + assert!( + state.begin_request(id(2)), + "the first click owns the action" + ); + assert!(state.is_in_flight(&id(2))); + assert!( + !state.begin_request(id(2)), + "a second click must not claim an action that is already running" + ); + assert!( + state.begin_request(id(3)), + "the guard is per request, not global" + ); + } + + #[test] + fn resolving_a_request_releases_its_guard_and_leaves_the_others() { + let mut state = ContactsState::default(); + state.begin_request(id(2)); + state.begin_request(id(3)); + + state.remove_request(&id(2)); + + assert!(!state.is_in_flight(&id(2))); + assert!( + state.is_in_flight(&id(3)), + "an unrelated request must keep its guard" + ); + } + + #[test] + fn clearing_the_guards_makes_every_row_actionable_again() { + let mut state = ContactsState::default(); + state.begin_request(id(2)); + + state.clear_in_flight(); + + assert!(!state.is_in_flight(&id(2))); + assert!( + state.begin_request(id(2)), + "after a failure the user must be able to retry the row" + ); + } + + #[test] + fn reset_clears_the_in_flight_guards() { + let mut state = ContactsState::default(); + state.begin_request(id(2)); + + state.reset(); + + assert!( + !state.is_in_flight(&id(2)), + "leaving the tab must not carry a guard into the next entry" + ); + } + #[test] fn contact_label_follows_nickname_display_username_id_priority() { assert_eq!( diff --git a/tests/backend-e2e/dashpay_tasks.rs b/tests/backend-e2e/dashpay_tasks.rs index 867d164ea..dba0928af 100644 --- a/tests/backend-e2e/dashpay_tasks.rs +++ b/tests/backend-e2e/dashpay_tasks.rs @@ -19,6 +19,7 @@ use crate::framework::task_runner::{run_task, run_task_with_nonce_retry}; use dash_evo_tool::backend_task::dashpay::DashPayTask; use dash_evo_tool::backend_task::identity::IdentityTask; use dash_evo_tool::backend_task::{BackendTask, BackendTaskSuccessResult}; +use dash_evo_tool::model::dashpay::AcceptedAccounts; use dash_evo_tool::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -689,7 +690,7 @@ async fn step_update_contact_info( nickname: Some("Test Nickname".into()), note: Some("E2E note".into()), is_hidden: false, - accepted_accounts: vec![0], + accepted_accounts: AcceptedAccounts::Replace(vec![0]), })); let result = run_task_with_nonce_retry(&ctx.app_context, task) From 453cd151b9962900f39b2e9ef5745368ca52fa3d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:05:57 +0000 Subject: [PATCH 23/30] feat(migration): import legacy v0.9.3 identities and their keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::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 --- src/app.rs | 17 + src/app/reconcilers.rs | 16 +- src/backend_task/migration/finish_unwire.rs | 528 +++++++++++++- src/backend_task/migration/v093_upgrade.rs | 724 +++++++++++++++++++- src/context/identity_db.rs | 20 + src/context/migration_status.rs | 18 + src/database/legacy_import.rs | 369 ++++++++++ src/ui/wallets/shielded_tab.rs | 11 +- 8 files changed, 1652 insertions(+), 51 deletions(-) diff --git a/src/app.rs b/src/app.rs index 913c6be16..8561ea702 100644 --- a/src/app.rs +++ b/src/app.rs @@ -132,6 +132,7 @@ pub fn migration_running_text(step: MigrationStep) -> &'static str { MigrationStep::Shielded => "Verifying shielded balance.", MigrationStep::WalletSeeds => "Moving your wallets into the new vault.", MigrationStep::WalletMeta => "Updating wallet names.", + MigrationStep::Identities => "Restoring your identities and their keys.", MigrationStep::Finalize => "Finishing storage update.", } } @@ -149,6 +150,20 @@ pub fn migration_unreadable_votes_text(count: u32) -> String { ) } +/// User-facing banner copy for a migration that finished the wallet drain but +/// could not decode `count` identities. Their keys are therefore not loaded, so +/// the sentence names the action that restores them. Kept separate from the +/// scheduled-votes copy because the remedy is different — load an identity, not +/// re-schedule a vote. The previous version's data is never deleted, so the +/// re-import is always possible. Exposed for kittest coverage. +pub fn migration_unreadable_identities_text(count: u32) -> String { + format!( + "Some identities from the previous version could not be read and were not carried over \ + ({count} in total). Your previous data is untouched. Load these identities again to \ + restore their keys." + ) +} + /// How long the cold-start readiness gate waits for the wallet backend to wire /// before it stops retrying silently and surfaces a visible, actionable banner. /// @@ -1840,6 +1855,7 @@ mod migration_banner_tests { MigrationStep::Shielded, MigrationStep::WalletSeeds, MigrationStep::WalletMeta, + MigrationStep::Identities, MigrationStep::Finalize, ] { let text = migration_running_text(step); @@ -1863,6 +1879,7 @@ mod migration_banner_tests { migration_running_text(MigrationStep::Shielded), migration_running_text(MigrationStep::WalletSeeds), migration_running_text(MigrationStep::WalletMeta), + migration_running_text(MigrationStep::Identities), migration_running_text(MigrationStep::Finalize), ]; let unique: std::collections::HashSet<&str> = labels.iter().copied().collect(); diff --git a/src/app/reconcilers.rs b/src/app/reconcilers.rs index 6ebec49f1..17ded53b2 100644 --- a/src/app/reconcilers.rs +++ b/src/app/reconcilers.rs @@ -33,7 +33,8 @@ use super::{ COLD_START_BACKEND_READY_TIMEOUT, COLD_START_STUCK_MESSAGE, MIGRATION_RETRY_ACTION_ID, SPV_CONNECTING_DESCRIPTION, SPV_CONTINUE_BACKGROUND_ACTION, SPV_SYNCING_DESCRIPTION, SpvBlockStep, cold_start_backend_wait_timed_out, migration_running_text, - migration_unreadable_votes_text, should_dispatch_cold_start, spv_block_step, + migration_unreadable_identities_text, migration_unreadable_votes_text, + should_dispatch_cold_start, spv_block_step, }; /// Drives platform-level accessibility (AccessKit) activation on the first @@ -487,6 +488,19 @@ impl MigrationReconciler { handle.disable_auto_dismiss(); self.banner_handle = Some(handle); } + MigrationState::SucceededWithUnreadableIdentities { count } => { + // Same shape as the vote warning: the drain is done and the rows + // are still in the previous version's storage, so no retry action + // — but the keys those identities held are not loaded, so the user + // must be told even if they stepped away. + let handle = MessageBanner::set_global( + ctx, + migration_unreadable_identities_text(count), + MessageType::Warning, + ); + handle.disable_auto_dismiss(); + self.banner_handle = Some(handle); + } MigrationState::Failed { error } => { if error.is_backend_not_ready() { // Transient: the wallet backend had not finished wiring when diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 13252972b..5eed32e28 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -9,12 +9,15 @@ use std::sync::Arc; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::platform::Identifier; use rusqlite::Connection; use serde::{Deserialize, Serialize}; use crate::backend_task::error::TaskError; use crate::context::AppContext; use crate::context::migration_status::{MigrationState, MigrationStep}; +use crate::model::qualified_identity::QualifiedIdentity; +use crate::model::wallet::WalletSeedHash; use crate::wallet_backend::{DetScope, KvAdapterError, network_prefix}; /// Sentinel key format string. The migration body filters every @@ -42,16 +45,18 @@ pub fn sentinel_key_for(network: Network) -> String { /// migration into the `Running` state. Ordered so the cheapest check /// (the single-row `wallet` table) runs first. /// -/// `scheduled_votes` and `top_up` are in the list because an install can -/// hold them with no wallet rows at all — a masternode voter who imported -/// identity keys directly has queued votes but no HD wallet. Omitting them -/// would leave the detection gate closed and drop those votes. +/// `scheduled_votes`, `top_up` and `identity` are in the list because an +/// install can hold them with no wallet rows at all — a masternode voter who +/// imported identity keys directly has identities and queued votes but no HD +/// wallet. Omitting them would leave the detection gate closed and drop that +/// user's keys. const LEGACY_TABLES: &[&str] = &[ "wallet", "single_key_wallet", "utxos", "scheduled_votes", "top_up", + "identity", ]; /// Persisted sentinel payload. Lives in `det-app.sqlite` under the @@ -166,6 +171,16 @@ pub enum MigrationError { source: Box, }, + /// A decoded legacy identity could not be written into the identity store. + /// Hard failure: the identity sentinel stays unwritten so the next launch + /// retries the idempotent import. Never blocks the wallet drain — [`run`] + /// judges this only after the seeds are safe. + #[error("could not save an identity from the previous version")] + IdentityImportFailed { + #[source] + source: Box, + }, + /// The wallet backend was not yet wired when the migration ran. /// This is a hard configuration bug: the orchestrator runs after /// `ensure_wallet_backend`, so this should never fire in @@ -231,27 +246,30 @@ impl MigrationError { /// decide whether to surface a "storage update complete" banner — a no-op /// launch must not show one. /// -/// Two independent passes, in this order: +/// Three independent passes, each under its own sentinel, in this order: /// /// 1. **App data** (scheduled votes, top-up history) — DET-owned rows the -/// wallet drain never touched, under their own sentinel. +/// wallet drain never touched. /// 2. **Wallet drain** (single keys, HD seeds, wallet metadata, upstream /// registration) — the pass that restores access to funds. +/// 3. **Identities** (identity rows and the keys they hold) — last, because it +/// needs the drain's output: a wired backend, a reachable vault, and a +/// hydrated `ctx.wallets` for wallet-derived identity keys to attach to. /// -/// The wallet drain runs **regardless of the app-data outcome**. The two are -/// only coupled at the end, when the terminal state is published: a legacy vote -/// row that cannot be imported must never stand between the user and their -/// seeds. +/// The wallet drain runs **regardless of the app-data outcome**. They are only +/// coupled at the end, when the terminal state is published: a legacy vote row +/// that cannot be imported must never stand between the user and their seeds. /// /// # Errors /// /// [`TaskError::MigrationFailed`] when the wallet drain fails (the completion /// sentinel stays unwritten, so the next launch retries), or when the wallet -/// drain succeeded but the app-data pass hit a hard failure — an unreadable -/// legacy file, a k/v write error. Undecodable vote rows are *not* an error: -/// they are counted and reported on [`MigrationState::SucceededWithUnreadableVotes`], -/// because a retry cannot decode a corrupt row and failing here would wedge the -/// wallet drain on every launch. +/// drain succeeded but the app-data or identity pass hit a hard failure — an +/// unreadable legacy file, a k/v write error. Undecodable *rows* are not an +/// error: they are counted and reported on +/// [`MigrationState::SucceededWithUnreadableVotes`] / +/// [`MigrationState::SucceededWithUnreadableIdentities`], because failing here +/// would wedge the wallet drain behind a row the user cannot repair. pub async fn run(app_context: &Arc) -> Result { let status = app_context.migration_status(); @@ -288,7 +306,34 @@ pub async fn run(app_context: &Arc) -> Result { // and reaches the user's "Retry now" banner; that retry re-runs the import // alone, since the wallet drain short-circuits on its own sentinel. let app_data = app_data?; - let moved_data = wallet_moved || app_data.moved_data(); + + // Identities import last: it needs the drain's output — the backend wired, + // the vault reachable, and `ctx.wallets` hydrated — so that a wallet-derived + // identity key lands against a wallet that actually exists. + status.set_state(MigrationState::Running { + step: MigrationStep::Identities, + }); + let identities = migrate_identities(app_context)?; + + let moved_data = wallet_moved || app_data.moved_data() || identities.moved_data(); + + // Identities outrank votes when both are damaged: an identity that did not + // come across took its keys with it, so the user cannot sign — let alone + // vote — until it is loaded again. Both counts are logged; one banner shows. + if identities.unreadable > 0 { + tracing::warn!( + target = "migration::finish_unwire", + unreadable = identities.unreadable, + imported = identities.imported, + votes_unreadable = app_data.votes_unreadable, + network = ?app_context.network, + "Some legacy identities could not be decoded; they stay in the previous version's data.db and must be loaded again", + ); + status.set_state(MigrationState::SucceededWithUnreadableIdentities { + count: identities.unreadable, + }); + return Ok(moved_data); + } if app_data.votes_unreadable > 0 { tracing::warn!( @@ -505,7 +550,7 @@ fn migrate_app_data(app_context: &Arc) -> Result) -> Result Result<(), MigrationError> { @@ -646,6 +692,187 @@ where Ok(outcome) } +/// Per-network sentinel for the legacy identity import. Distinct from the +/// wallet-drain sentinel on purpose: every install that already drained its +/// wallets under an earlier build (which had no identity import) still has its +/// identities — and their owner / voting keys — sitting in `data.db`. Sharing +/// the drain's sentinel would declare exactly those installs "done" and drop +/// them. +pub fn identities_sentinel_key_for(network: Network) -> String { + format!("det:migration:identities:{}:v1", network_prefix(network)) +} + +/// Import the legacy `identity` rows — and the private keys they carry — into +/// the modern identity store. +/// +/// Runs after the wallet drain: the k/v store, the secret vault and a hydrated +/// `ctx.wallets` all have to exist first. Idempotent — an identity already in +/// the store is left alone, so a retry can never overwrite an alias the user has +/// since edited with the stale legacy copy. +/// +/// Key material is never handled here. Each decoded identity goes straight to +/// [`AppContext::insert_local_qualified_identity`], which routes the keys +/// through the vault seam and writes only `InVault` placeholders to disk. The +/// sentinel is withheld while any row is unreadable, so a later build with a +/// fixed decoder retries — the legacy rows are never deleted. +/// +/// # Errors +/// +/// [`TaskError::MigrationFailed`] when the legacy file cannot be opened or read, +/// or a decoded identity cannot be written to the store. Undecodable rows are +/// not an error — they are counted and reported to the user by [`run`]. +fn migrate_identities( + app_context: &Arc, +) -> Result { + let app_kv = app_context.app_kv(); + let network = app_context.network; + let sentinel_key = identities_sentinel_key_for(network); + + let done = app_kv + .get::(DetScope::Global, &sentinel_key) + .map_err(|source| MigrationError::Sentinel { source })? + .is_some(); + if done { + return Ok(IdentityMigrationOutcome::default()); + } + + let Some(path) = app_context.db.db_file_path() else { + // In-memory / headless: no legacy file to import from. + return Ok(IdentityMigrationOutcome::default()); + }; + if !path.exists() { + return Ok(IdentityMigrationOutcome::default()); + } + let conn = Connection::open(&path).map_err(|e| MigrationError::LegacyDbOpen { + path: path.to_string_lossy().to_string(), + source: e, + })?; + + // Own probe rather than the drain's [`detect_legacy_rows`] gate: an + // identity-only install (a masternode voter with no HD wallet) has to import + // its identities even though the wallet family is empty. Probing first also + // keeps a fresh install from needing the backend it has no data for. + if !table_has_rows(&conn, "identity")? { + write_completion_sentinel(&app_kv, &sentinel_key)?; + return Ok(IdentityMigrationOutcome::default()); + } + + let backend = app_context + .wallet_backend() + .map_err(|_| MigrationError::WalletBackendUnavailable)?; + + let outcome = migrate_identities_from_conn( + &conn, + network, + |seed_hash| backend.wallet_meta().get(network, seed_hash).is_some(), + |id| app_context.has_local_qualified_identity(id), + |qi, wallet| app_context.insert_local_qualified_identity(qi, wallet), + )?; + + tracing::info!( + target = "migration::finish_unwire", + imported = outcome.imported, + skipped_existing = outcome.skipped_existing, + unreadable = outcome.unreadable, + network = ?network, + "Identity migration pass complete", + ); + + // Sentinel iff everything decoded. An unreadable blob may be a *decoder* + // defect (a bincode drift), not data rot, so the door stays open for a later + // build to retry; the skip-if-present rule makes that retry a no-op for + // every identity that already landed. + if outcome.unreadable == 0 { + write_completion_sentinel(&app_kv, &sentinel_key)?; + } + + Ok(outcome) +} + +/// Pure identity-import body (testable without an `AppContext`). +/// +/// `wallet_known` reports whether the identity's linked wallet actually made it +/// across; `is_present` is the skip-if-already-imported check; `insert` is the +/// vault-routing writer. +fn migrate_identities_from_conn( + conn: &Connection, + network: Network, + wallet_known: W, + mut is_present: P, + mut insert: I, +) -> Result +where + W: Fn(&WalletSeedHash) -> bool, + P: FnMut(&Identifier) -> Result, + I: FnMut(&QualifiedIdentity, &Option<(WalletSeedHash, u32)>) -> Result<(), TaskError>, +{ + let import_failed = |source: TaskError| MigrationError::IdentityImportFailed { + source: Box::new(source), + }; + + let legacy = + crate::database::legacy_import::read_identities(conn, network).map_err(|source| { + MigrationError::LegacyDbRead { + table: "identity", + source, + } + })?; + + let mut outcome = IdentityMigrationOutcome { + unreadable: legacy.unreadable, + ..Default::default() + }; + + for row in legacy.identities { + let id = Identifier::from(row.id); + + // INSERT-OR-REPLACE below, so this check is what keeps a retry from + // overwriting an identity the user has edited since it was imported. + if is_present(&id).map_err(import_failed)? { + outcome.skipped_existing = outcome.skipped_existing.saturating_add(1); + continue; + } + + // A link to a wallet that did not come across (or was never there) is + // preserved verbatim, never nulled: it is what re-attaches the identity + // when that wallet is restored or unlocked later. + if let Some((seed_hash, _)) = row.wallet + && !wallet_known(&seed_hash) + { + tracing::warn!( + target = "migration::finish_unwire", + identity = %hex::encode(row.id), + "Importing an identity whose wallet is not present; the link is kept so it re-attaches when that wallet is restored", + ); + } + + insert(&row.qi, &row.wallet).map_err(import_failed)?; + outcome.imported = outcome.imported.saturating_add(1); + } + + Ok(outcome) +} + +/// Outcome counters from one [`migrate_identities`] pass. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct IdentityMigrationOutcome { + /// Identities written into the per-network identity store, keys vaulted. + imported: u32, + /// Identities already in the store and therefore left untouched. + skipped_existing: u32, + /// Legacy rows that could not be decoded. Withholds the sentinel so a later + /// build can retry, but never fails the pass — the identities that *did* + /// decode must not be held hostage by one that did not. + unreadable: u32, +} + +impl IdentityMigrationOutcome { + /// `true` when this pass actually moved an identity across. + fn moved_data(&self) -> bool { + self.imported > 0 + } +} + /// Returns `true` when any of the [`LEGACY_TABLES`] holds at least one /// row. Missing tables are treated as empty: a freshly-installed /// `data.db` already lacks the dropped tables, and that is correct. @@ -1881,6 +2108,265 @@ mod tests { } } + // ── Identity import ────────────────────────────────────────────── + + mod identities { + use super::*; + use crate::model::qualified_identity::IdentityType; + use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::identity::accessors::IdentityGettersV0; + use dash_sdk::dpp::version::PlatformVersion; + use std::cell::RefCell; + + const NETWORK: Network = Network::Testnet; + + fn create_identity_table(conn: &Connection) { + conn.execute_batch( + "CREATE TABLE identity ( + id BLOB PRIMARY KEY, + data BLOB, + status INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL, + alias TEXT, + info TEXT, + wallet BLOB, + wallet_index INTEGER, + identity_type TEXT, + network TEXT NOT NULL + );", + ) + .expect("create identity table"); + } + + /// A minimal, genuinely-encodable identity blob. + fn identity_blob(id: [u8; 32]) -> Vec { + let identity = dash_sdk::dpp::identity::Identity::create_basic_identity( + Identifier::from(id), + PlatformVersion::latest(), + ) + .expect("basic identity"); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: None, + private_keys: Default::default(), + dpns_names: vec![], + associated_wallets: std::collections::BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: Default::default(), + status: Default::default(), + network: NETWORK, + } + .to_bytes() + } + + fn insert_identity(conn: &Connection, id: [u8; 32], data: Option>, is_local: bool) { + conn.execute( + "INSERT INTO identity (id, data, status, is_local, network) + VALUES (?1, ?2, 2, ?3, ?4)", + rusqlite::params![ + id.as_slice(), + data, + i64::from(is_local), + NETWORK.to_string() + ], + ) + .expect("insert identity"); + } + + /// Collects what the importer tried to write, so a test can assert on the + /// identities that reached the (vault-routing) writer. + #[derive(Default)] + struct Recorder { + imported: RefCell>, + } + + /// A blob that will never decode must not take the identities around it + /// down with it: the readable rows still import, and the failure is + /// counted so the caller can withhold the sentinel and retry later. + #[test] + fn an_undecodable_blob_never_blocks_the_identities_that_do_decode() { + let conn = Connection::open_in_memory().expect("in-memory db"); + create_identity_table(&conn); + let good = [0xAA; 32]; + let corrupt = [0xBB; 32]; + insert_identity(&conn, good, Some(identity_blob(good)), true); + insert_identity(&conn, corrupt, Some(vec![0xFF; 16]), true); + + let recorder = Recorder::default(); + let outcome = migrate_identities_from_conn( + &conn, + NETWORK, + |_| true, + |_| Ok(false), + |qi, _| { + recorder + .imported + .borrow_mut() + .push(qi.identity.id().to_buffer()); + Ok(()) + }, + ) + .expect("an undecodable row must not fail the pass"); + + assert_eq!(outcome.imported, 1, "the readable identity still imports"); + assert_eq!(outcome.unreadable, 1, "the corrupt row is reported"); + assert_eq!( + *recorder.imported.borrow(), + vec![good], + "only the decodable identity reaches the writer", + ); + assert!( + outcome.unreadable > 0, + "a non-zero `unreadable` is what withholds the sentinel, keeping the \ + retry door open for a build with a fixed decoder", + ); + } + + /// An identity already in the store is left alone. The writer is + /// INSERT-OR-REPLACE, so re-importing would overwrite whatever the user + /// has done to it since (renamed it, added a key) with the legacy copy. + #[test] + fn an_identity_already_in_the_store_is_never_reimported() { + let conn = Connection::open_in_memory().expect("in-memory db"); + create_identity_table(&conn); + let existing = [0xAA; 32]; + let fresh = [0xBB; 32]; + insert_identity(&conn, existing, Some(identity_blob(existing)), true); + insert_identity(&conn, fresh, Some(identity_blob(fresh)), true); + + let recorder = Recorder::default(); + let outcome = migrate_identities_from_conn( + &conn, + NETWORK, + |_| true, + |id| Ok(id.to_buffer() == existing), + |qi, _| { + recorder + .imported + .borrow_mut() + .push(qi.identity.id().to_buffer()); + Ok(()) + }, + ) + .expect("import"); + + assert_eq!(outcome.imported, 1); + assert_eq!(outcome.skipped_existing, 1); + assert_eq!( + *recorder.imported.borrow(), + vec![fresh], + "the already-present identity must never reach the writer", + ); + } + + /// Observed (`is_local = 0`) rows are v0.9.3's lookup cache and NULL-blob + /// rows have nothing in them. Neither is user data: both are skipped, and + /// neither counts as a failure that would withhold the sentinel forever. + #[test] + fn observed_and_null_blob_rows_are_skipped_without_failing_the_pass() { + let conn = Connection::open_in_memory().expect("in-memory db"); + create_identity_table(&conn); + let observed = [0xAA; 32]; + let null_blob = [0xBB; 32]; + insert_identity(&conn, observed, Some(identity_blob(observed)), false); + insert_identity(&conn, null_blob, None, true); + + let recorder = Recorder::default(); + let outcome = migrate_identities_from_conn( + &conn, + NETWORK, + |_| true, + |_| Ok(false), + |qi, _| { + recorder + .imported + .borrow_mut() + .push(qi.identity.id().to_buffer()); + Ok(()) + }, + ) + .expect("import"); + + assert_eq!( + ( + outcome.imported, + outcome.unreadable, + outcome.skipped_existing + ), + (0, 0, 0), + "neither row is user data, and neither is a failure", + ); + assert!(recorder.imported.borrow().is_empty()); + } + + /// A link to a wallet that did not come across is kept verbatim, never + /// nulled: it is what re-attaches the identity when the user finally + /// unlocks (or restores) that wallet. + #[test] + fn a_link_to_an_absent_wallet_is_preserved_not_nulled() { + let conn = Connection::open_in_memory().expect("in-memory db"); + create_identity_table(&conn); + let id = [0xAA; 32]; + let orphan_wallet = [0x77; 32]; + conn.execute( + "INSERT INTO identity (id, data, status, is_local, wallet, wallet_index, network) + VALUES (?1, ?2, 2, 1, ?3, 4, ?4)", + rusqlite::params![ + id.as_slice(), + identity_blob(id), + orphan_wallet.as_slice(), + NETWORK.to_string() + ], + ) + .expect("insert identity"); + + let links: RefCell>> = RefCell::new(Vec::new()); + let outcome = migrate_identities_from_conn( + &conn, + NETWORK, + // The wallet is unknown — it failed to migrate, or is locked. + |_| false, + |_| Ok(false), + |_, wallet| { + links.borrow_mut().push(*wallet); + Ok(()) + }, + ) + .expect("import"); + + assert_eq!(outcome.imported, 1, "the identity imports anyway"); + assert_eq!( + *links.borrow(), + vec![Some((orphan_wallet, 4))], + "the wallet link must survive verbatim — nulling it would orphan the \ + identity from its keys permanently", + ); + } + + /// An identity-only install (a masternode voter with no HD wallet) must + /// still trip the detection gate, or its keys are never even looked at. + #[test] + fn detection_gate_covers_the_identity_table() { + assert!(LEGACY_TABLES.contains(&"identity")); + } + + /// The identity sentinel is per network and distinct from the wallet + /// drain's: reusing the latter would skip the import for every install + /// that already drained under a build without an identity importer. + #[test] + fn sentinel_is_per_network_and_distinct_from_the_other_sentinels() { + let testnet = identities_sentinel_key_for(Network::Testnet); + assert_ne!(testnet, identities_sentinel_key_for(Network::Mainnet)); + assert_ne!(testnet, sentinel_key_for(Network::Testnet)); + assert_ne!(testnet, app_data_sentinel_key_for(Network::Testnet)); + } + } + /// Round-trip: writing the sentinel and reading it back yields the /// same payload. Guards the codec from accidental shape drift. #[test] diff --git a/src/backend_task/migration/v093_upgrade.rs b/src/backend_task/migration/v093_upgrade.rs index dee54fb16..29ede34f6 100644 --- a/src/backend_task/migration/v093_upgrade.rs +++ b/src/backend_task/migration/v093_upgrade.rs @@ -18,23 +18,42 @@ //! no `core_wallet_name` column, no `onboarding_completed` column, and seeds //! stored raw with empty salt/nonce. +use std::collections::BTreeMap; +use std::str::FromStr; use std::sync::Arc; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; +use dash_sdk::dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dash_sdk::dpp::identity::v0::IdentityV0; +use dash_sdk::dpp::identity::{ + Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel, +}; +use dash_sdk::dpp::key_wallet::bip32::DerivationPath; +use dash_sdk::dpp::platform_value::BinaryData; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; use rusqlite::{Connection, params}; -use crate::backend_task::migration::finish_unwire::{self, MigrationCompletion, sentinel_key_for}; +use crate::backend_task::migration::finish_unwire::{ + self, MigrationCompletion, identities_sentinel_key_for, sentinel_key_for, +}; use crate::backend_task::migration::legacy_settings::{SettingsImport, import_legacy_settings}; use crate::context::AppContext; use crate::database::Database; use crate::database::test_helpers::{create_database_at_path, legacy_master_epk_bytes}; +use crate::model::qualified_identity::encrypted_key_storage::{ + KeyStorage, PrivateKeyData, WalletDerivationPath, +}; +use crate::model::qualified_identity::qualified_identity_public_key::QualifiedIdentityPublicKey; +use crate::model::qualified_identity::{ + IdentityStatus, IdentityType, PrivateKeyTarget, QualifiedIdentity, +}; use crate::model::settings::{AppSettings, RootScreenType, ThemeMode}; use crate::model::wallet::encryption::encrypt_message; use crate::model::wallet::{ClosedKeyItem, WalletSeedHash}; use crate::wallet_backend::secret_seam::SecretScheme; -use crate::wallet_backend::{DetScope, WalletBackend}; +use crate::wallet_backend::{DetScope, IdentityKeyView, WalletBackend}; /// `DEFAULT_DB_VERSION` as shipped by v0.9.3. Every upgrading user's `data.db` /// enters the ladder here. @@ -47,10 +66,133 @@ const USER_NETWORK: Network = Network::Testnet; const UNPROTECTED_SEED: [u8; 64] = [0xA9; 64]; const PROTECTED_SEED: [u8; 64] = [0xC7; 64]; const PROTECTED_PASSWORD: &str = "correct horse battery staple"; -const IDENTITY_ID: [u8; 32] = [0xBB; 32]; const TOP_UP_AMOUNT: u64 = 123_456; const CONTESTED_NAME: &str = "quantum"; +/// Row A — the masternode identity whose owner + voting keys are the whole +/// point of the identity import. Its `data` blob is [`V093_MASTERNODE_BLOB_HEX`]. +const IDENTITY_ID: [u8; 32] = [0xBB; 32]; +/// The voter identity associated with row A (`associated_voter_identity`). +const VOTER_IDENTITY_ID: [u8; 32] = [0xBC; 32]; +/// Row B — a wallet-derived `User` identity on the unprotected wallet. +const USER_IDENTITY_ID: [u8; 32] = [0xCC; 32]; +/// Row C — an `Evonode` identity with no wallet at all (loaded by ProTxHash). +const EVONODE_IDENTITY_ID: [u8; 32] = [0xDD; 32]; +/// Row D — an observed (`is_local = 0`) identity: lookup cache, not user data. +const OBSERVED_IDENTITY_ID: [u8; 32] = [0xEE; 32]; +/// Row E — a `User` identity on the password-protected (locked) wallet. +const PROTECTED_IDENTITY_ID: [u8; 32] = [0xCD; 32]; +/// Row F — a local row whose `data` blob is NULL (v0.9.3 allowed it). +const NULL_BLOB_IDENTITY_ID: [u8; 32] = [0xEF; 32]; + +/// The masternode owner key held `Clear` in row A's legacy blob. After the +/// import it must exist only in the vault — never in `det-app.sqlite`. +const OWNER_PRIVATE_KEY: [u8; 32] = [0x11; 32]; +/// The masternode voting key held `Clear` in row A's legacy blob. +const VOTING_PRIVATE_KEY: [u8; 32] = [0x22; 32]; +/// The `Clear` key held by the wallet-less evonode identity (row C). +const EVONODE_PRIVATE_KEY: [u8; 32] = [0x33; 32]; + +/// Legacy `identity.status` column values. The bincode blob does **not** carry +/// status, so these are what prove the column is restored on import rather than +/// every identity reading back as `Unknown`. +const STATUS_ACTIVE: u8 = 2; +const STATUS_PENDING_CREATION: u8 = 1; +const STATUS_NOT_FOUND: u8 = 3; + +/// A **genuine v0.9.3** `QualifiedIdentity::to_bytes()` — the masternode +/// identity of row A, carrying `Clear` owner and voting keys. +/// +/// This is the wire-format contract between the two builds. v0.9.3 encodes with +/// bincode `2.0.0-rc.3`; this tree decodes with `2.0.1`. Everything else about +/// the import is reasoning about struct layout — this constant is the only proof +/// that the actual bytes a real user has on disk still decode here. +/// +/// To regenerate: build a throwaway crate (not a workspace member) depending on +/// `dash-evo-tool` tag `v0.9.3`, construct the identity below — id +/// [`IDENTITY_ID`], voter id [`VOTER_IDENTITY_ID`], type `Masternode`, alias +/// `my-masternode`, `associated_owner_key_id = Some(0)`, an `OWNER` key (id 0, +/// `Clear`([`OWNER_PRIVATE_KEY`]), `ECDSA_HASH160`) on +/// `PrivateKeyOnMainIdentity` and a `VOTING` key (id 1, +/// `Clear`([`VOTING_PRIVATE_KEY`]), `ECDSA_HASH160`) on +/// `PrivateKeyOnVoterIdentity` — and print `hex::encode(qi.to_bytes())`. +const V093_MASTERNODE_BLOB_HEX: &str = "00bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb02000000060000020014fc7250a211deddc70ee5a2738de5f07817351cef00010001050000020014531260aa2a199e228c537dfa42c82bea2c7c1f4d00fc40420f00010100bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc01010001050000020014531260aa2a199e228c537dfa42c82bea2c7c1f4d00fc40420f00010001050000020014531260aa2a199e228c537dfa42c82bea2c7c1f4d0000010001010d6d792d6d61737465726e6f64650200000000060000020014fc7250a211deddc70ee5a2738de5f07817351cef000001111111111111111111111111111111111111111111111111111111111111111101010001050000020014531260aa2a199e228c537dfa42c82bea2c7c1f4d000001222222222222222222222222222222222222222222222222222222222222222200"; + +/// The legacy blob of row A, exactly as a v0.9.3 install holds it on disk. +fn v093_masternode_blob() -> Vec { + hex::decode(V093_MASTERNODE_BLOB_HEX).expect("the golden blob is valid hex") +} + +fn identity_public_key(id: KeyID, purpose: Purpose) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level: SecurityLevel::MASTER, + contract_bounds: None, + key_type: KeyType::ECDSA_HASH160, + read_only: false, + // 20 bytes: the shape of a hash160 key. The import never checks a key + // against its public data, so a stand-in value is enough here — row A's + // keys, the ones that matter, come from the real v0.9.3 blob instead. + data: BinaryData::new(vec![id as u8; 20]), + disabled_at: None, + }) +} + +/// Encode a `QualifiedIdentity` the way v0.9.3 did — same manual `Encode`, same +/// bincode config. Used for the fixture rows whose exact bytes do not matter; +/// row A instead carries the real v0.9.3 blob, which is what pins the wire format. +fn legacy_identity_blob( + id: [u8; 32], + identity_type: IdentityType, + alias: &str, + keys: Vec<(PrivateKeyTarget, KeyID, Purpose, PrivateKeyData)>, +) -> Vec { + let mut private_keys = BTreeMap::new(); + let mut public_keys = BTreeMap::new(); + for (target, key_id, purpose, data) in keys { + let public_key = identity_public_key(key_id, purpose); + public_keys.insert(key_id, public_key.clone()); + private_keys.insert( + (target, key_id), + (QualifiedIdentityPublicKey::from(public_key), data), + ); + } + + QualifiedIdentity { + identity: Identity::V0(IdentityV0 { + id: Identifier::from(id), + public_keys, + balance: 1_000_000, + revision: 1, + }), + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type, + alias: Some(alias.to_string()), + private_keys: KeyStorage::from(private_keys), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: BTreeMap::new(), + // Never encoded — the legacy `status` column is the only source, which is + // exactly what the import has to restore. + status: IdentityStatus::Unknown, + network: USER_NETWORK, + } + .to_bytes() +} + +/// A key the wallet derives on demand — no secret bytes in the blob at all. +fn wallet_derived_key(seed_hash: WalletSeedHash) -> PrivateKeyData { + PrivateKeyData::AtWalletDerivationPath(WalletDerivationPath { + wallet_seed_hash: seed_hash, + derivation_path: DerivationPath::from_str("m/9'/1'/5'/0'/0'").expect("derivation path"), + }) +} + /// The seed vault + metadata sidecar state of the two migrated wallets, plus /// the envelope bytes the fixture wrote, so a test can assert the protected /// envelope travelled byte-for-byte. @@ -64,6 +206,37 @@ struct Fixture { protected_nonce: Vec, } +/// Insert one row into the legacy `identity` table, in v0.9.3's column shape. +#[allow(clippy::too_many_arguments)] +fn insert_identity( + conn: &Connection, + id: [u8; 32], + data: Option>, + status: u8, + is_local: bool, + alias: &str, + wallet: Option<(WalletSeedHash, u32)>, + identity_type: &str, +) { + conn.execute( + "INSERT INTO identity + (id, data, status, is_local, alias, wallet, wallet_index, identity_type, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + id.as_slice(), + data, + status, + i64::from(is_local), + alias, + wallet.map(|(seed_hash, _)| seed_hash.to_vec()), + wallet.map(|(_, index)| index), + identity_type, + USER_NETWORK.to_string(), + ], + ) + .expect("insert identity row"); +} + /// Write a `data.db` in the exact shape v0.9.3 left on disk, then hand back the /// keys the assertions need. /// @@ -313,21 +486,120 @@ fn write_v093_database(dir: &std::path::Path) -> Fixture { ) .expect("insert protected wallet row"); - // A masternode identity owned by the unprotected wallet. `data` is the opaque - // bincode blob v0.9.3 wrote (`QualifiedIdentity::to_bytes`); no current code - // path decodes it — see the module note on the identity-import gap. - conn.execute( - "INSERT INTO identity - (id, data, status, is_local, alias, wallet, wallet_index, identity_type, network) - VALUES (?1, ?2, 0, 1, 'my-masternode', ?3, 0, 'Masternode', ?4)", - params![ - IDENTITY_ID.as_slice(), - vec![0u8; 16], - unprotected.as_slice(), - USER_NETWORK.to_string(), - ], - ) - .expect("insert identity row"); + // Row A — the masternode identity owned by the unprotected wallet, holding + // the user's owner + voting keys `Clear`. `data` is a REAL v0.9.3 blob, so + // this row also pins the cross-version bincode wire format. + insert_identity( + &conn, + IDENTITY_ID, + Some(v093_masternode_blob()), + STATUS_ACTIVE, + true, + "my-masternode", + Some((unprotected, 0)), + "Masternode", + ); + + // Row B — a `User` identity whose key is wallet-derived: nothing secret in + // the blob, but the wallet link must survive or the key cannot be derived. + insert_identity( + &conn, + USER_IDENTITY_ID, + Some(legacy_identity_blob( + USER_IDENTITY_ID, + IdentityType::User, + "my-username", + vec![( + PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + Purpose::AUTHENTICATION, + wallet_derived_key(unprotected), + )], + )), + STATUS_PENDING_CREATION, + true, + "my-username", + Some((unprotected, 1)), + "User", + ); + + // Row C — an evonode identity with no wallet at all (loaded by ProTxHash), + // holding a `Clear` key. A wallet-less identity must still import. + insert_identity( + &conn, + EVONODE_IDENTITY_ID, + Some(legacy_identity_blob( + EVONODE_IDENTITY_ID, + IdentityType::Evonode, + "my-evonode", + vec![( + PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + Purpose::OWNER, + PrivateKeyData::Clear(EVONODE_PRIVATE_KEY), + )], + )), + STATUS_NOT_FOUND, + true, + "my-evonode", + None, + "Evonode", + ); + + // Row D — an observed identity: v0.9.3's lookup cache, not the user's own. + // Importing it would put a stranger's identity on the Identities screen. + insert_identity( + &conn, + OBSERVED_IDENTITY_ID, + Some(legacy_identity_blob( + OBSERVED_IDENTITY_ID, + IdentityType::User, + "someone-else", + vec![], + )), + STATUS_ACTIVE, + false, + "someone-else", + None, + "User", + ); + + // Row E — a `User` identity on the password-protected wallet. That wallet is + // still locked after the drain, so this is the row that proves a locked + // wallet does not cost the user the identity, nor its link to that wallet. + insert_identity( + &conn, + PROTECTED_IDENTITY_ID, + Some(legacy_identity_blob( + PROTECTED_IDENTITY_ID, + IdentityType::User, + "cold-username", + vec![( + PrivateKeyTarget::PrivateKeyOnMainIdentity, + 0, + Purpose::AUTHENTICATION, + wallet_derived_key(protected), + )], + )), + STATUS_ACTIVE, + true, + "cold-username", + Some((protected, 0)), + "User", + ); + + // Row F — v0.9.3 allowed a local row with no blob. Nothing to import; it must + // be skipped silently, not counted as a failure. + insert_identity( + &conn, + NULL_BLOB_IDENTITY_ID, + None, + STATUS_ACTIVE, + true, + "no-data", + None, + "User", + ); conn.execute( "INSERT INTO scheduled_votes @@ -441,6 +713,45 @@ fn fresh_install_schema_version() -> u16 { schema_version_at(&db_file) } +/// The persisted shape of an identity entry, mirroring the private +/// `context::identity_db::StoredQualifiedIdentity`. Lets the test read what +/// actually landed **on disk** rather than trusting the in-memory struct — the +/// only way to prove no plaintext key survived the import. Field order is the +/// bincode contract; a drift in the real struct surfaces here as a decode error. +#[derive(serde::Deserialize)] +struct StoredIdentityOnDisk { + qi_bytes: Vec, + status: u8, + identity_type: String, + wallet_hash: Option<[u8; 32]>, + wallet_index: Option, +} + +/// Read the raw stored entry for one identity out of the per-network k/v store. +fn stored_identity(ctx: &Arc, id: [u8; 32]) -> StoredIdentityOnDisk { + ctx.det_kv() + .expect("per-network k/v") + .get::(DetScope::Identity(&id), "det:identity:v1") + .expect("read stored identity") + .expect("the identity must be stored after the import") +} + +/// Every private key in a stored blob, as it sits on disk. +fn stored_key_data(stored: &StoredIdentityOnDisk) -> Vec { + QualifiedIdentity::from_bytes(&stored.qi_bytes) + .expect("the stored blob must decode") + .private_keys + .private_keys + .values() + .map(|(_, data)| data.clone()) + .collect() +} + +/// `true` if `needle` appears anywhere in `haystack`. +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + fn top_up_history(ctx: &Arc) -> Option> { // `det:top_ups:v1` is `context::identity_db::TOP_UPS_KEY`, which is private to // that module. A key change surfaces here as a missing entry, not a silent pass. @@ -631,10 +942,9 @@ async fn v093_install_upgrades_with_wallets_settings_votes_and_history_intact() "the top-up audit trail must come across, scoped by its identity's network", ); - // ── Identity ───────────────────────────────────────────────────── - // The identity row survives the ladder with its wallet link intact. It is the - // join the top-up import scopes on, and the source a future identity importer - // reads (see the module note): the ladder must not drop or orphan it. + // ── Identity: the legacy row survives the ladder ───────────────── + // The precondition the import consumes: the ladder must not drop or orphan + // the row it reads from. let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); let (alias, wallet, wallet_index, identity_type, network): ( String, @@ -663,9 +973,172 @@ async fn v093_install_upgrades_with_wallets_settings_votes_and_history_intact() "a testnet identity must not be swept into mainnet by the v33 network rename", ); + // ── Identity import: the user's identities and their keys ──────── + let identities = ctx + .load_local_qualified_identities() + .expect("load imported identities"); + let ids: Vec<[u8; 32]> = identities + .iter() + .map(|qi| qi.identity.id().to_buffer()) + .collect(); + // Sorted by identity id — the documented order of `load_identities_filtered`. + assert_eq!( + ids, + vec![ + IDENTITY_ID, + USER_IDENTITY_ID, + PROTECTED_IDENTITY_ID, + EVONODE_IDENTITY_ID + ], + "every local identity must come across — and only those: an observed \ + (is_local = 0) row is a lookup cache, and a NULL-blob row has nothing to import", + ); + + let masternode = identities + .iter() + .find(|qi| qi.identity.id().to_buffer() == IDENTITY_ID) + .expect("the masternode identity must be imported"); + assert_eq!(masternode.alias.as_deref(), Some("my-masternode")); + assert_eq!(masternode.identity_type, IdentityType::Masternode); + assert_eq!(masternode.wallet_index, Some(0)); + assert_eq!( + masternode.status, + IdentityStatus::Active, + "status lives in a column, not the blob — dropping it would relabel every \ + imported identity as `Unknown, refresh required`", + ); + + // The whole point of the import: the masternode owner still controls the node. + let presence = masternode.masternode_key_presence(); + assert!( + presence.owner && presence.voting, + "the owner and voting keys the user had loaded must come across, not be lost \ + to a manual re-import: got {presence:?}", + ); + + // Type-filtered views — the Masternodes and Identities screens read these. + let masternode_ids: Vec<[u8; 32]> = ctx + .load_local_masternode_identities() + .expect("load masternode identities") + .iter() + .map(|qi| qi.identity.id().to_buffer()) + .collect(); + assert_eq!( + masternode_ids, + vec![IDENTITY_ID, EVONODE_IDENTITY_ID], + "the Masternodes screen must show the masternode and the evonode", + ); + let user_ids: Vec<[u8; 32]> = ctx + .load_local_user_identities() + .expect("load user identities") + .iter() + .map(|qi| qi.identity.id().to_buffer()) + .collect(); + assert_eq!( + user_ids, + vec![USER_IDENTITY_ID, PROTECTED_IDENTITY_ID], + "the Identities screen must show both user identities", + ); + + // ── Wallet links survive, including to a still-locked wallet ───── + let stored_masternode = stored_identity(&ctx, IDENTITY_ID); + let stored_protected = stored_identity(&ctx, PROTECTED_IDENTITY_ID); + assert_eq!( + (stored_protected.wallet_hash, stored_protected.wallet_index), + (Some(fixture.protected), Some(0)), + "an identity on a locked wallet keeps its link — that link is what re-attaches \ + it when the user unlocks the wallet", + ); + assert_eq!( + ( + stored_masternode.wallet_hash, + stored_masternode.wallet_index + ), + (Some(fixture.unprotected), Some(0)), + ); + assert_eq!( + stored_masternode.identity_type, "Masternode", + "the type tag backs the filtered loads without a full blob decode", + ); + let stored_evonode = stored_identity(&ctx, EVONODE_IDENTITY_ID); + assert_eq!( + (stored_evonode.wallet_hash, stored_evonode.wallet_index), + (None, None), + "a wallet-less identity must not acquire a wallet link", + ); + assert_eq!( + stored_evonode.status, STATUS_NOT_FOUND, + "each identity keeps its own status", + ); + assert_eq!( + stored_identity(&ctx, USER_IDENTITY_ID).status, + STATUS_PENDING_CREATION, + ); + + // The top-up history already imported under the same identity scope must + // still resolve — the two entries share a scope and must not collide. + assert_eq!( + top_up_history(&ctx), + Some(std::collections::BTreeMap::from([(0, TOP_UP_AMOUNT)])), + "the identity blob must not displace the top-up entry in the same scope", + ); + backend.shutdown().await; } +/// The cross-version wire contract, in isolation: a `QualifiedIdentity` encoded +/// by the **real v0.9.3 binary** (bincode `2.0.0-rc.3`) still decodes on this +/// tree (bincode `2.0.1`). +/// +/// Everything else about the identity import is reasoning about struct layout. +/// This is the one test that reads bytes a real user actually has on disk. If +/// bincode's wire format had drifted between the two versions, this fails — and +/// the import would be dropping masternode keys on the floor. +#[test] +fn a_real_v093_identity_blob_still_decodes() { + let qi = QualifiedIdentity::from_bytes(&v093_masternode_blob()) + .expect("a genuine v0.9.3 identity blob must decode on the current bincode"); + + assert_eq!(qi.identity.id().to_buffer(), IDENTITY_ID); + assert_eq!(qi.identity_type, IdentityType::Masternode); + assert_eq!(qi.alias.as_deref(), Some("my-masternode")); + assert_eq!(qi.associated_owner_key_id, Some(0)); + assert_eq!( + qi.associated_voter_identity + .as_ref() + .map(|(identity, _)| identity.id().to_buffer()), + Some(VOTER_IDENTITY_ID), + "the associated voter identity must survive the decode", + ); + + // The keys are the payload. v0.9.3 held them `Clear`, and they must decode + // to exactly the bytes it wrote — a shifted field or a changed varint would + // corrupt them silently. + let keys = &qi.private_keys.private_keys; + assert_eq!(keys.len(), 2, "both private keys must decode"); + assert_eq!( + keys.get(&(PrivateKeyTarget::PrivateKeyOnMainIdentity, 0)) + .map(|(_, data)| data.clone()), + Some(PrivateKeyData::Clear(OWNER_PRIVATE_KEY)), + "the masternode owner key must decode byte-for-byte", + ); + assert_eq!( + keys.get(&(PrivateKeyTarget::PrivateKeyOnVoterIdentity, 1)) + .map(|(_, data)| data.clone()), + Some(PrivateKeyData::Clear(VOTING_PRIVATE_KEY)), + "the masternode voting key must decode byte-for-byte", + ); + + // Status is not in the blob — the legacy column is its only source. This is + // the default the import has to overwrite. + assert_eq!( + qi.status, + IdentityStatus::Unknown, + "status is not encoded; a decoded blob must come back `Unknown` so the \ + importer's column restore is load-bearing", + ); +} + /// The second launch after an upgrade. Every step must short-circuit on its /// sentinel: no duplicated votes, no resurrected history, no rewritten sentinel, /// no clobbered preferences — and the legacy rows still in `data.db`, because a @@ -699,6 +1172,13 @@ async fn second_launch_after_a_v093_upgrade_changes_nothing() { ctx.clear_all_scheduled_votes() .expect("user casts the vote"); + // …and renames the imported masternode identity. On this (clean) path the + // identity sentinel is what must protect the edit; the skip-if-present rule + // is exercised by `a_retry_after_an_unreadable_identity_preserves_user_edits`, + // where the sentinel is deliberately withheld. + ctx.set_identity_alias(&Identifier::from(IDENTITY_ID), Some("my-renamed-node")) + .expect("user renames the identity"); + // Second launch: the boot import runs again, then the migration. assert_eq!( import_legacy_settings(&app_kv, &ctx.db).expect("second settings import"), @@ -736,6 +1216,23 @@ async fn second_launch_after_a_v093_upgrade_changes_nothing() { "the migrated wallet must stay reachable across launches", ); + // The skip-if-present rule: a re-run must not push the legacy blob back over + // an identity the user has since edited. + assert_eq!( + ctx.get_identity_alias(&Identifier::from(IDENTITY_ID)) + .expect("read alias") + .as_deref(), + Some("my-renamed-node"), + "a re-run must not overwrite the user's edit with the stale legacy identity", + ); + assert_eq!( + ctx.load_local_qualified_identities() + .expect("load identities") + .len(), + 4, + "a second launch must not duplicate or drop an identity", + ); + // The migration never deletes its source, so a later build can re-read it. let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); let wallets: i64 = conn @@ -744,11 +1241,190 @@ async fn second_launch_after_a_v093_upgrade_changes_nothing() { let votes: i64 = conn .query_row("SELECT COUNT(*) FROM scheduled_votes", [], |r| r.get(0)) .expect("count vote rows"); + let identities: i64 = conn + .query_row("SELECT COUNT(*) FROM identity", [], |r| r.get(0)) + .expect("count identity rows"); assert_eq!( - (wallets, votes), - (2, 1), + (wallets, votes, identities), + (2, 1, 6), "legacy rows must survive untouched" ); backend.shutdown().await; } + +/// The security contract: the import itself must never write a plaintext private +/// key to disk. +/// +/// v0.9.3 stored masternode owner / voting keys as `Clear` — plaintext, inside +/// the identity blob. The import has to route them through the vault seam, so +/// the at-rest blob keeps only `InVault` placeholders. +/// +/// This reads the stored bytes **immediately after the migration, before any +/// load path runs**, and that ordering is the whole point: reading them after a +/// `load_local_qualified_identities()` would prove nothing, because the eager +/// load-path repair (`migrate_identity_keys_to_vault`) vaults resident plaintext +/// on read and would mask an importer that had written it. "Repaired on next +/// read" is not a security property — the bytes must never hit the disk at all. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_import_never_writes_a_plaintext_key_to_disk() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_v093_database(tmp.path()); + + let (ctx, _) = boot(tmp.path()); + let backend = wire_backend(&ctx).await; + finish_unwire::run(&ctx).await.expect("migration"); + + // Deliberately no `load_*` call before these reads. + for (id, label) in [ + (IDENTITY_ID, "the masternode's owner + voting keys"), + (EVONODE_IDENTITY_ID, "the wallet-less evonode's key"), + ] { + let stored = stored_identity(&ctx, id); + let key_data = stored_key_data(&stored); + assert!( + !key_data.is_empty(), + "{label}: the keys must be stored, not dropped", + ); + assert!( + key_data + .iter() + .all(|data| matches!(data, PrivateKeyData::InVault)), + "{label}: a `Clear`/`AlwaysClear` key must never survive the import to disk — \ + it must be vaulted: got {key_data:?}", + ); + for key in [OWNER_PRIVATE_KEY, VOTING_PRIVATE_KEY, EVONODE_PRIVATE_KEY] { + assert!( + !contains_bytes(&stored.qi_bytes, &key), + "{label}: raw private-key bytes must appear nowhere in the stored blob", + ); + } + } + + // Vaulted, not destroyed: the keys are readable back, byte-for-byte, so the + // masternode owner can still sign. Silently losing them would be as bad as + // leaking them. + let secret_store = ctx.secret_store(); + let vault = IdentityKeyView::new(&secret_store, IDENTITY_ID); + assert_eq!( + vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .expect("read owner key from the vault") + .expect("the owner key must be in the vault") + .as_slice(), + OWNER_PRIVATE_KEY.as_slice(), + "the owner key must arrive in the vault intact", + ); + assert_eq!( + vault + .get(&PrivateKeyTarget::PrivateKeyOnVoterIdentity, 1) + .expect("read voting key from the vault") + .expect("the voting key must be in the vault") + .as_slice(), + VOTING_PRIVATE_KEY.as_slice(), + "the voting key must arrive in the vault intact", + ); + + backend.shutdown().await; +} + +/// The retry path, which is the only path where skip-if-present is load-bearing. +/// +/// An undecodable identity blob withholds the identity sentinel, so the **next** +/// launch runs the import again over identities that already landed. Without a +/// skip-if-present check, that re-run would push the stale legacy blob back over +/// them with `insert_local_qualified_identity`'s INSERT-OR-REPLACE — silently +/// undoing anything the user changed in between. +/// +/// (`second_launch_after_a_v093_upgrade_changes_nothing` cannot prove this: on a +/// clean upgrade the sentinel short-circuits the pass before the check is +/// reached.) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_retry_after_an_unreadable_identity_preserves_user_edits() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_v093_database(tmp.path()); + + // One corrupt blob is enough to withhold the sentinel for the whole pass. + let corrupt_id = [0x0C; 32]; + let conn = Connection::open(tmp.path().join("data.db")).expect("open data.db"); + insert_identity( + &conn, + corrupt_id, + Some(vec![0xFF; 16]), + STATUS_ACTIVE, + true, + "corrupt", + None, + "User", + ); + drop(conn); + + let (ctx, _) = boot(tmp.path()); + let backend = wire_backend(&ctx).await; + finish_unwire::run(&ctx).await.expect("first migration"); + + // The readable identities landed regardless of the corrupt row… + assert_eq!( + ctx.load_local_qualified_identities() + .expect("load identities") + .len(), + 4, + "an undecodable blob must not block the identities that do decode", + ); + // …the user is told… + assert_eq!( + *ctx.migration_status().state(), + crate::context::migration_status::MigrationState::SucceededWithUnreadableIdentities { + count: 1 + }, + "the user must learn that an identity did not come across", + ); + // …and the sentinel stays unwritten, so the next launch retries. + assert!( + ctx.app_kv() + .get::( + DetScope::Global, + &identities_sentinel_key_for(USER_NETWORK) + ) + .expect("read identity sentinel") + .is_none(), + "an unreadable row must withhold the sentinel, keeping the retry door open for \ + a later build with a fixed decoder", + ); + + // The user renames the imported masternode before the next launch. + ctx.set_identity_alias(&Identifier::from(IDENTITY_ID), Some("my-renamed-node")) + .expect("rename identity"); + + // Second launch: with no sentinel, the identity import genuinely runs again. + finish_unwire::run(&ctx).await.expect("retry migration"); + + assert_eq!( + ctx.get_identity_alias(&Identifier::from(IDENTITY_ID)) + .expect("read alias") + .as_deref(), + Some("my-renamed-node"), + "the retry must skip identities already imported — re-inserting would overwrite \ + the user's edit with the stale legacy blob", + ); + assert_eq!( + ctx.load_local_qualified_identities() + .expect("load identities") + .len(), + 4, + "the retry must not duplicate the identities it already imported", + ); + + backend.shutdown().await; +} + +/// The identity import carries its own sentinel. Reusing the wallet drain's +/// would silently skip the import for every install that already drained its +/// wallets under a build that had no identity importer — i.e. exactly the +/// installs this feature exists for. +#[test] +fn the_identity_sentinel_is_per_network_and_distinct_from_the_wallet_sentinel() { + let testnet = identities_sentinel_key_for(USER_NETWORK); + assert_ne!(testnet, identities_sentinel_key_for(Network::Mainnet)); + assert_ne!(testnet, sentinel_key_for(USER_NETWORK)); +} diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index d70e8c3d2..291fde34e 100644 --- a/src/context/identity_db.rs +++ b/src/context/identity_db.rs @@ -695,6 +695,26 @@ impl AppContext { Ok(Some(qi)) } + /// `true` when an identity blob is already stored under `id`. + /// + /// Presence only: the blob is never decoded, so this cannot touch key + /// material. Backs the legacy-identity import's skip-if-present rule — + /// [`Self::insert_local_qualified_identity`] is INSERT-OR-REPLACE, so a + /// retry without this check would overwrite an identity the user has since + /// edited with the stale legacy copy. Keys on the blob, not the enumeration + /// index, so a dangling index entry still imports. + pub(crate) fn has_local_qualified_identity( + &self, + id: &Identifier, + ) -> std::result::Result { + let kv = self.det_kv()?; + let id_buf = id.to_buffer(); + Ok(kv + .get::(DetScope::Identity(&id_buf), IDENTITY_KEY) + .map_err(identity_err)? + .is_some()) + } + /// Internal: read every stored identity via the Global enumeration /// index, decode it, rehydrate the metadata kept outside the bincode /// blob, and apply `keep` as a pre-decode filter on the wrapper. diff --git a/src/context/migration_status.rs b/src/context/migration_status.rs index e09186233..5265ad2ee 100644 --- a/src/context/migration_status.rs +++ b/src/context/migration_status.rs @@ -38,6 +38,11 @@ pub enum MigrationStep { /// / master xpub) into the DET wallet-metadata sidecar in /// `det-app.sqlite`. WalletMeta, + /// Importing legacy `identity` rows — and the owner / voting / payout + /// keys they carry — into the modern identity store. Runs after the + /// wallet drain so each identity's key lands against a wallet that + /// already exists. + Identities, /// Writing the completion sentinel and cleaning up. Finalize, } @@ -65,6 +70,14 @@ pub enum MigrationState { /// the legacy rows are never deleted, and a retry cannot decode a corrupt /// row, so the user is told once rather than offered a futile retry. SucceededWithUnreadableVotes { count: u32 }, + /// The wallet drain completed, but `count` legacy identities could not be + /// decoded and did not come across — so the keys they held (a masternode's + /// owner / voting key, say) are not loaded. Terminal and non-fatal: the + /// legacy rows are never deleted and the import sentinel stays unwritten, + /// so a later build with a fixed decoder retries automatically. Separate + /// from [`Self::SucceededWithUnreadableVotes`] because the remedy differs — + /// re-import a key, not re-schedule a vote. + SucceededWithUnreadableIdentities { count: u32 }, /// Migration failed. The wrapped error is rendered for the user via /// its `Display` impl at banner-render time; the typed chain is /// preserved for the details panel and logs. @@ -89,6 +102,10 @@ impl PartialEq for MigrationState { MigrationState::SucceededWithUnreadableVotes { count: a }, MigrationState::SucceededWithUnreadableVotes { count: b }, ) => a == b, + ( + MigrationState::SucceededWithUnreadableIdentities { count: a }, + MigrationState::SucceededWithUnreadableIdentities { count: b }, + ) => a == b, (MigrationState::Running { step: a }, MigrationState::Running { step: b }) => a == b, (MigrationState::Failed { error: a }, MigrationState::Failed { error: b }) => { Arc::ptr_eq(a, b) @@ -176,6 +193,7 @@ mod tests { MigrationStep::Shielded, MigrationStep::WalletSeeds, MigrationStep::WalletMeta, + MigrationStep::Identities, MigrationStep::Finalize, ] { status.set_state(MigrationState::Running { step }); diff --git a/src/database/legacy_import.rs b/src/database/legacy_import.rs index 665ee13b5..219c7a591 100644 --- a/src/database/legacy_import.rs +++ b/src/database/legacy_import.rs @@ -21,6 +21,7 @@ use rusqlite::Connection; use crate::backend_task::contested_names::ScheduledDPNSVote; use crate::database::{Database, column_exists, table_exists}; +use crate::model::qualified_identity::{IdentityStatus, QualifiedIdentity}; use crate::model::settings::{ AppSettings, RootScreenType, UserMode, network_from_legacy_str, theme_mode_from_str, }; @@ -49,6 +50,38 @@ pub(crate) struct LegacyScheduledVotes { /// persists (`top_up_index -> amount`). pub(crate) type LegacyTopUps = Vec<([u8; 32], BTreeMap)>; +/// One decoded local identity from the legacy `identity` table. +/// +/// `qi` carries the identity's private keys, so it is moved straight into +/// [`AppContext::insert_local_qualified_identity`](crate::context::AppContext::insert_local_qualified_identity) +/// — the only writer that routes key material through the vault seam. +#[derive(Debug)] +pub(crate) struct LegacyIdentityRow { + /// 32-byte identity id, the k/v scope key. + pub id: [u8; 32], + /// The decoded identity, with `status` and `network` already restored + /// from their columns — the bincode blob carries neither. + pub qi: QualifiedIdentity, + /// `(wallet seed hash, account index)` of the owning wallet. `None` for a + /// wallet-less identity (a masternode loaded by ProTxHash). Both-or-neither, + /// enforced by the legacy `CHECK` constraint. + pub wallet: Option<([u8; 32], u32)>, +} + +/// Outcome of one legacy identity read. +/// +/// `unreadable` counts rows whose blob could not be decoded. The caller +/// withholds the completion sentinel while it is non-zero, so a later build +/// with a fixed decoder can still pick those rows up — unlike a corrupt vote, +/// an undecodable identity blob may be a decoder defect, not data rot. +#[derive(Debug, Default)] +pub(crate) struct LegacyIdentities { + /// Rows decoded into the modern domain type. + pub identities: Vec, + /// Rows that failed to decode. Never silently dropped by the caller. + pub unreadable: u32, +} + /// Read the user preferences held in the legacy `settings` row. /// /// Returns `None` when the table or its singleton row is absent (a fresh @@ -222,6 +255,103 @@ pub(crate) fn read_scheduled_votes( Ok(out) } +/// Read the local identities — and the private keys they hold — for `network`. +/// +/// Only `is_local = 1` rows with a non-NULL `data` blob are user identities. +/// v0.9.3 also cached observed identities (`is_local = 0`, often a NULL blob); +/// every one of its own read paths filtered those out, and so does this one — +/// they are a lookup cache, not the user's data. +/// +/// A row whose blob will not decode is counted in +/// [`LegacyIdentities::unreadable`] and skipped, so one bad blob never blocks +/// the identities around it. Nothing about the blob — or the decoded identity — +/// is ever logged: it carries private keys. +pub(crate) fn read_identities( + conn: &Connection, + network: Network, +) -> rusqlite::Result { + if !table_exists(conn, "identity")? { + return Ok(LegacyIdentities::default()); + } + + let mut stmt = conn.prepare( + "SELECT id, data, status, wallet, wallet_index FROM identity \ + WHERE is_local = 1 AND data IS NOT NULL AND network IN (?1, ?2)", + )?; + let mut rows = stmt.query(rusqlite::params![ + network.to_string(), + mainnet_alias_for(network) + ])?; + + let mut out = LegacyIdentities::default(); + while let Some(row) = rows.next()? { + let id: Vec = row.get(0)?; + let data: Vec = row.get(1)?; + let status: u8 = row.get(2)?; + let wallet: Option> = row.get(3)?; + let wallet_index: Option = row.get(4)?; + + let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) else { + tracing::warn!( + target = "database::legacy_import", + blob_len = id.len(), + "Skipping legacy identity with a non-32-byte id", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + }; + + // Both-or-neither: the legacy `CHECK` guarantees it, so a half-filled + // link is corruption. Dropping just the link would silently orphan the + // identity from the wallet that owns its keys, so the row is reported + // instead — the sentinel stays open and a later build can retry it. + let wallet = match (wallet, wallet_index) { + (None, None) => None, + (Some(seed_hash), Some(index)) => match <[u8; 32]>::try_from(seed_hash.as_slice()) { + Ok(seed_hash) => Some((seed_hash, index)), + Err(_) => { + tracing::warn!( + target = "database::legacy_import", + identity = %hex::encode(id), + "Skipping legacy identity whose wallet link is not a 32-byte seed hash", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + } + }, + _ => { + tracing::warn!( + target = "database::legacy_import", + identity = %hex::encode(id), + "Skipping legacy identity with a half-filled wallet link", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + } + }; + + let Ok(mut qi) = QualifiedIdentity::from_bytes(&data) else { + tracing::warn!( + target = "database::legacy_import", + identity = %hex::encode(id), + "Skipping legacy identity whose stored data could not be decoded", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + }; + + // Neither field is in the bincode blob — the legacy encoder skipped both + // and kept them in columns. Without this, every imported identity reads + // back as `Unknown` status on mainnet. + qi.status = IdentityStatus::from(status); + qi.network = network; + + out.identities.push(LegacyIdentityRow { id, qi, wallet }); + } + + Ok(out) +} + /// Read the top-up history of every identity on `network`. /// /// The legacy `top_up` table carries no network column, so rows are scoped @@ -688,4 +818,243 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); assert!(read_top_ups(&conn, Network::Testnet).unwrap().is_empty()); } + + // ── Identities ─────────────────────────────────────────────────── + + fn create_identity_table(conn: &Connection) { + conn.execute_batch( + "CREATE TABLE identity ( + id BLOB PRIMARY KEY, + data BLOB, + status INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL, + alias TEXT, + info TEXT, + wallet BLOB, + wallet_index INTEGER, + identity_type TEXT, + network TEXT NOT NULL + );", + ) + .unwrap(); + } + + /// A genuinely-encodable identity blob, in the legacy `to_bytes()` shape. + fn identity_blob(id: [u8; 32]) -> Vec { + use crate::model::qualified_identity::IdentityType; + use dash_sdk::dpp::version::PlatformVersion; + + let identity = dash_sdk::dpp::identity::Identity::create_basic_identity( + Identifier::from(id), + PlatformVersion::latest(), + ) + .unwrap(); + QualifiedIdentity { + identity, + associated_voter_identity: None, + associated_operator_identity: None, + associated_owner_key_id: None, + identity_type: IdentityType::User, + alias: Some("alias".to_string()), + private_keys: Default::default(), + dpns_names: vec![], + associated_wallets: BTreeMap::new(), + secret_access: None, + wallet_index: None, + top_ups: Default::default(), + status: Default::default(), + network: Network::Testnet, + } + .to_bytes() + } + + #[allow(clippy::too_many_arguments)] + fn insert_identity( + conn: &Connection, + id: [u8; 32], + data: Option>, + status: u8, + is_local: bool, + wallet: Option<(Vec, u32)>, + network: &str, + ) { + conn.execute( + "INSERT INTO identity (id, data, status, is_local, wallet, wallet_index, network) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + rusqlite::params![ + id.as_slice(), + data, + status, + i64::from(is_local), + wallet.as_ref().map(|(hash, _)| hash.clone()), + wallet.as_ref().map(|(_, index)| *index), + network, + ], + ) + .unwrap(); + } + + /// The status column is the only source of an identity's status — the + /// bincode blob does not carry it. Losing it relabels every migrated + /// identity as "Unknown, refresh required". + #[test] + fn identities_restore_status_from_its_column() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + let id = [0xAA; 32]; + insert_identity(&conn, id, Some(identity_blob(id)), 2, true, None, "testnet"); + + let read = read_identities(&conn, Network::Testnet).unwrap(); + + assert_eq!(read.identities.len(), 1); + assert_eq!(read.identities[0].qi.status, IdentityStatus::Active); + assert_eq!(read.identities[0].qi.network, Network::Testnet); + assert_eq!(read.identities[0].id, id); + assert_eq!(read.unreadable, 0); + } + + /// v0.9.3 cached observed identities with `is_local = 0` and often a NULL + /// blob. They are a lookup cache, not the user's own identities — every + /// v0.9.3 read path filtered them out and so must this one. + #[test] + fn identities_skip_observed_and_null_blob_rows() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + let mine = [0xAA; 32]; + let observed = [0xBB; 32]; + let null_blob = [0xCC; 32]; + insert_identity( + &conn, + mine, + Some(identity_blob(mine)), + 2, + true, + None, + "testnet", + ); + insert_identity( + &conn, + observed, + Some(identity_blob(observed)), + 2, + false, + None, + "testnet", + ); + insert_identity(&conn, null_blob, None, 2, true, None, "testnet"); + + let read = read_identities(&conn, Network::Testnet).unwrap(); + + assert_eq!(read.identities.len(), 1, "only the local, non-null row"); + assert_eq!(read.identities[0].id, mine); + assert_eq!( + read.unreadable, 0, + "a skipped cache row is not a failure — counting it would withhold the \ + sentinel forever", + ); + } + + /// The wallet link is what re-attaches an identity to the wallet holding its + /// keys. It must survive the read exactly as stored. + #[test] + fn identities_carry_their_wallet_link() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + let id = [0xAA; 32]; + let seed_hash = [0x77; 32]; + insert_identity( + &conn, + id, + Some(identity_blob(id)), + 2, + true, + Some((seed_hash.to_vec(), 3)), + "testnet", + ); + + let read = read_identities(&conn, Network::Testnet).unwrap(); + assert_eq!(read.identities[0].wallet, Some((seed_hash, 3))); + } + + /// A corrupt blob is counted, never silently dropped, and never blocks the + /// identities around it. + #[test] + fn identities_count_unreadable_rows() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + let good = [0xAA; 32]; + let corrupt = [0xBB; 32]; + insert_identity( + &conn, + good, + Some(identity_blob(good)), + 2, + true, + None, + "testnet", + ); + insert_identity( + &conn, + corrupt, + Some(vec![0xFF; 8]), + 2, + true, + None, + "testnet", + ); + + let read = read_identities(&conn, Network::Testnet).unwrap(); + + assert_eq!(read.identities.len(), 1, "the readable identity survives"); + assert_eq!(read.identities[0].id, good); + assert_eq!(read.unreadable, 1); + } + + /// Identities on another network must not leak into this network's import. + /// A pre-v29 `data.db` still spells mainnet `dash`. + #[test] + fn identities_filter_by_network_including_the_legacy_mainnet_spelling() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + let testnet_id = [0xAA; 32]; + let legacy_mainnet_id = [0xBB; 32]; + insert_identity( + &conn, + testnet_id, + Some(identity_blob(testnet_id)), + 2, + true, + None, + "testnet", + ); + insert_identity( + &conn, + legacy_mainnet_id, + Some(identity_blob(legacy_mainnet_id)), + 2, + true, + None, + LEGACY_MAINNET_ALIAS, + ); + + let testnet = read_identities(&conn, Network::Testnet).unwrap(); + assert_eq!(testnet.identities.len(), 1); + assert_eq!(testnet.identities[0].id, testnet_id); + + let mainnet = read_identities(&conn, Network::Mainnet).unwrap(); + assert_eq!( + mainnet.identities.len(), + 1, + "a pre-v29 mainnet identity must still be found on mainnet", + ); + assert_eq!(mainnet.identities[0].id, legacy_mainnet_id); + } + + #[test] + fn identities_absent_table_reads_empty() { + let conn = Connection::open_in_memory().unwrap(); + let read = read_identities(&conn, Network::Testnet).unwrap(); + assert!(read.identities.is_empty()); + assert_eq!(read.unreadable, 0); + } } diff --git a/src/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index de80ff004..d1b616bb2 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -75,11 +75,12 @@ pub fn derive_shielded_indicator(state: &MigrationState, skipped: bool) -> Shiel step: MigrationStep::Shielded, } => ShieldedIndicator::Verifying, MigrationState::Failed { .. } => ShieldedIndicator::Failed, - // Unreadable scheduled votes say nothing about shielded data: the wallet - // drain completed, so the balance is as authoritative as on `Success`. - MigrationState::Success | MigrationState::SucceededWithUnreadableVotes { .. } => { - ShieldedIndicator::Verified - } + // Unreadable scheduled votes or identities say nothing about shielded + // data: the wallet drain completed, so the balance is as authoritative + // as on `Success`. + MigrationState::Success + | MigrationState::SucceededWithUnreadableVotes { .. } + | MigrationState::SucceededWithUnreadableIdentities { .. } => ShieldedIndicator::Verified, // Idle / non-shielded running step → no badge. MigrationState::Idle | MigrationState::Running { .. } => ShieldedIndicator::Hidden, } From ce1205aa13720522ce38738d713f253595301fdf Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:32:39 +0000 Subject: [PATCH 24/30] fix(migration): never let a corrupt vote queue strand identity keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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::` / `row.get::`, 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 --- src/backend_task/migration/finish_unwire.rs | 52 +++++++++--- src/backend_task/migration/v093_upgrade.rs | 62 +++++++++++++++ src/database/legacy_import.rs | 88 ++++++++++++++++++++- 3 files changed, 187 insertions(+), 15 deletions(-) diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 5eed32e28..9ded2f558 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -256,9 +256,13 @@ impl MigrationError { /// needs the drain's output: a wired backend, a reachable vault, and a /// hydrated `ctx.wallets` for wallet-derived identity keys to attach to. /// -/// The wallet drain runs **regardless of the app-data outcome**. They are only -/// coupled at the end, when the terminal state is published: a legacy vote row -/// that cannot be imported must never stand between the user and their seeds. +/// **No pass gates another.** The wallet drain runs regardless of the app-data +/// outcome, and the identity import runs regardless of *both* — its result is +/// judged only at the end, alongside theirs. A legacy vote row that cannot be +/// imported must never stand between the user and their seeds, nor between the +/// user and their identity keys: an app-data failure is deterministic, so +/// letting it short-circuit the identity import would strand those keys outside +/// the vault on every launch, not just this one. /// /// # Errors /// @@ -301,19 +305,41 @@ pub async fn run(app_context: &Arc) -> Result { } }; - // Funds are reachable from here on, so the app-data outcome can now decide - // the terminal state. A hard failure leaves the app-data sentinel unwritten - // and reaches the user's "Retry now" banner; that retry re-runs the import - // alone, since the wallet drain short-circuits on its own sentinel. - let app_data = app_data?; - - // Identities import last: it needs the drain's output — the backend wired, - // the vault reachable, and `ctx.wallets` hydrated — so that a wallet-derived - // identity key lands against a wallet that actually exists. + // Funds are reachable from here on. The identity import runs next, and its + // result — like the app-data one — is held rather than propagated, because + // the two DET-owned passes must not gate each other. + // + // The identity pass needs the drain's output (backend wired, vault reachable, + // `ctx.wallets` hydrated) so a wallet-derived key lands against a wallet that + // exists. It must NOT wait on the app-data result: a hard app-data failure — + // one malformed vote-index blob is enough — is deterministic, so unwrapping + // it first would skip the identity import on this launch *and every retry* + // (the app-data sentinel is never written, so the failure recurs forever). + // That would strand a masternode owner's private keys outside the vault + // permanently, over a corrupt vote queue. status.set_state(MigrationState::Running { step: MigrationStep::Identities, }); - let identities = migrate_identities(app_context)?; + let identities = migrate_identities(app_context); + + // Both DET-owned passes have now run. A hard failure in either still reaches + // the user's "Retry now" banner, but only after neither could block the + // other. The identity failure takes precedence when both fail: keys outrank + // votes. + let identities = match identities { + Ok(outcome) => outcome, + Err(identity_error) => { + if let Err(app_data_error) = &app_data { + tracing::warn!( + target = "migration::finish_unwire", + error = ?app_data_error, + "App-data import failed on the same launch as the identity import; both retry on the next launch", + ); + } + return Err(identity_error); + } + }; + let app_data = app_data?; let moved_data = wallet_moved || app_data.moved_data() || identities.moved_data(); diff --git a/src/backend_task/migration/v093_upgrade.rs b/src/backend_task/migration/v093_upgrade.rs index 29ede34f6..da30425a0 100644 --- a/src/backend_task/migration/v093_upgrade.rs +++ b/src/backend_task/migration/v093_upgrade.rs @@ -1328,6 +1328,68 @@ async fn the_import_never_writes_a_plaintext_key_to_disk() { backend.shutdown().await; } +/// A corrupt vote queue must never cost the user their identity keys. +/// +/// The app-data pass (scheduled votes, top-up history) can fail hard — one +/// malformed `det:scheduled_vote_voters:v1` blob is enough. That failure is +/// deterministic: it recurs on every launch, and the app-data sentinel is never +/// written. So if the identity import waited on the app-data result, a masternode +/// owner's owner/voting keys would never reach the vault — not on this launch, +/// not on any retry — because of a broken vote queue they cannot even see. +/// +/// The two DET-owned passes are independent; neither may gate the other. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_corrupt_vote_index_never_strands_the_identity_keys() { + let tmp = tempfile::tempdir().expect("tempdir"); + write_v093_database(tmp.path()); + + let (ctx, _) = boot(tmp.path()); + let backend = wire_backend(&ctx).await; + + // Poison the scheduled-vote roster with a blob that cannot decode into the + // voter list, so the app-data pass fails hard the way a corrupted entry would. + // (`det:scheduled_vote_voters:v1` is private to `context::identity_db`; a key + // rename surfaces here as an un-poisoned index, not a silent pass.) + ctx.det_kv() + .expect("per-network k/v") + .put( + DetScope::Global, + "det:scheduled_vote_voters:v1", + &vec![0xFFu8; 3], + ) + .expect("poison the vote index"); + + // The failure still reaches the user — it is not swallowed… + assert!( + finish_unwire::run(&ctx).await.is_err(), + "a hard app-data failure must still surface, so the user gets a retry", + ); + + // …but it must not have taken the identities down with it. + assert_eq!( + ctx.load_local_qualified_identities() + .expect("load identities") + .len(), + 4, + "a corrupt vote queue must not strand the user's identities", + ); + + let secret_store = ctx.secret_store(); + let vault = IdentityKeyView::new(&secret_store, IDENTITY_ID); + assert_eq!( + vault + .get(&PrivateKeyTarget::PrivateKeyOnMainIdentity, 0) + .expect("read owner key from the vault") + .expect("the owner key must be in the vault") + .as_slice(), + OWNER_PRIVATE_KEY.as_slice(), + "the masternode owner key must reach the vault even when the vote import fails — \ + otherwise a broken vote queue permanently costs the user control of their node", + ); + + backend.shutdown().await; +} + /// The retry path, which is the only path where skip-if-present is load-bearing. /// /// An undecodable identity blob withholds the identity sentinel, so the **next** diff --git a/src/database/legacy_import.rs b/src/database/legacy_import.rs index 219c7a591..584bbf6ea 100644 --- a/src/database/legacy_import.rs +++ b/src/database/legacy_import.rs @@ -287,9 +287,15 @@ pub(crate) fn read_identities( while let Some(row) = rows.next()? { let id: Vec = row.get(0)?; let data: Vec = row.get(1)?; - let status: u8 = row.get(2)?; + // SQLite stores both of these as signed 64-bit integers and the legacy + // schema puts no `CHECK` on either, so a corrupted row can hold a value + // that does not fit. Widen the read and convert explicitly: a narrow + // `row.get::` would raise `IntegralValueOutOfRange` through `?` and + // take the whole identity read down — every other row-level corruption + // here is counted and skipped, and this one must behave the same. + let status: i64 = row.get(2)?; let wallet: Option> = row.get(3)?; - let wallet_index: Option = row.get(4)?; + let wallet_index: Option = row.get(4)?; let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) else { tracing::warn!( @@ -301,6 +307,30 @@ pub(crate) fn read_identities( continue; }; + let Ok(status) = u8::try_from(status) else { + tracing::warn!( + target = "database::legacy_import", + identity = %hex::encode(id), + "Skipping legacy identity with an out-of-range status value", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + }; + + let wallet_index = match wallet_index.map(u32::try_from) { + None => None, + Some(Ok(index)) => Some(index), + Some(Err(_)) => { + tracing::warn!( + target = "database::legacy_import", + identity = %hex::encode(id), + "Skipping legacy identity with an out-of-range wallet index", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + } + }; + // Both-or-neither: the legacy `CHECK` guarantees it, so a half-filled // link is corruption. Dropping just the link would silently orphan the // identity from the wallet that owns its keys, so the row is reported @@ -1050,6 +1080,60 @@ mod tests { assert_eq!(mainnet.identities[0].id, legacy_mainnet_id); } + /// An out-of-range `status` / `wallet_index` is row-level corruption like + /// any other: counted, skipped, and never allowed to take down the whole + /// read. SQLite puts no `CHECK` on either column, so a corrupted value + /// (300 does not fit a `u8`) is storable — and a narrow `row.get` would + /// raise `IntegralValueOutOfRange` through `?`, losing every readable + /// identity alongside it, keys included. + #[test] + fn identities_skip_out_of_range_column_values_without_failing_the_read() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + let good = [0xAA; 32]; + let bad_status = [0xBB; 32]; + let bad_index = [0xCC; 32]; + + insert_identity( + &conn, + good, + Some(identity_blob(good)), + 2, + true, + None, + "testnet", + ); + // `status` and `wallet_index` are bound as raw integers, past the u8/u32 + // range the modern types accept. + conn.execute( + "INSERT INTO identity (id, data, status, is_local, network) + VALUES (?1, ?2, 300, 1, 'testnet')", + rusqlite::params![bad_status.as_slice(), identity_blob(bad_status)], + ) + .unwrap(); + conn.execute( + "INSERT INTO identity (id, data, status, is_local, wallet, wallet_index, network) + VALUES (?1, ?2, 2, 1, ?3, 4294967296, 'testnet')", + rusqlite::params![ + bad_index.as_slice(), + identity_blob(bad_index), + [0x77u8; 32].as_slice() + ], + ) + .unwrap(); + + let read = read_identities(&conn, Network::Testnet) + .expect("an out-of-range column must not fail the whole read"); + + assert_eq!( + read.identities.len(), + 1, + "the readable identity must still come across", + ); + assert_eq!(read.identities[0].id, good); + assert_eq!(read.unreadable, 2, "both corrupt rows are reported"); + } + #[test] fn identities_absent_table_reads_empty() { let conn = Connection::open_in_memory().unwrap(); From 54e93748e6fcd52ad7c6d4fbf9cd8d28258ea127 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:48:19 +0000 Subject: [PATCH 25/30] 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 --- tests/legacy_table_surface.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/legacy_table_surface.rs b/tests/legacy_table_surface.rs index 8cb2985cb..12ac4ddec 100644 --- a/tests/legacy_table_surface.rs +++ b/tests/legacy_table_surface.rs @@ -70,6 +70,12 @@ const ALLOW_LIST: &[&str] = &[ "src/backend_task/migration/mod.rs", // Test-only helper modules colocated with prod code. "src/database/contract.rs", + // v0.9.3 -> v1.0 upgrade-path lock test: asserts the legacy `wallet` + // row count survives migration by reading it directly from a scratch + // fixture database. A test-only fixture-verification read, the sibling + // of `wallet_lifecycle/tests.rs`'s exemption above — never a cold-boot + // read. + "src/backend_task/migration/v093_upgrade.rs", ]; fn allow_list() -> BTreeSet { From 64a6d3d1e3273e3b60511cca5ff2385dd230cba3 Mon Sep 17 00:00:00 2001 From: lklimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:54:51 +0200 Subject: [PATCH 26/30] test: stabilize suite timing and close kittest wiring race (#884) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 --------- Co-authored-by: Claude Sonnet 5 --- Cargo.toml | 14 ++++++++ src/context/wallet_lifecycle/tests.rs | 27 +++++++++++---- tests/kittest/contract_screen.rs | 3 +- tests/kittest/dashpay_screen.rs | 3 +- tests/kittest/register_dpns_name_screen.rs | 3 +- tests/kittest/support.rs | 39 ++++++++++++++++++++-- tests/kittest/tokens_screen.rs | 3 +- tests/kittest/tools_screen.rs | 3 +- 8 files changed, 77 insertions(+), 18 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bd7d3f9cf..d69b83b7e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -151,6 +151,20 @@ winres = "0.1" [profile.dev.package."*"] debug = "line-tables-only" +# Argon2id is deliberately memory-hard; unoptimized (opt-level=0) each 64 MiB +# derivation runs for seconds AND holds its memory that whole time, so the +# wallet_backend secret-storage tests both ran slowly (5-23s each) and, under +# whole-suite parallelism, overlapped enough to push the host into swap. +# opt-level=3 shrinks each derivation to tens of ms, which also collapses the +# memory-hold window and the swap pressure. This mirrors platform's own root +# argon2 stanza, which does NOT propagate downstream: Cargo honors [profile.*] +# tables only from the workspace root, so DET must declare its own. +[profile.dev.package.argon2] +opt-level = 3 + +[profile.test.package.argon2] +opt-level = 3 + [lints.rust.unexpected_cfgs] level = "warn" check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\", \"bench\", \"mcp\", \"cli\", \"headless\"))"] diff --git a/src/context/wallet_lifecycle/tests.rs b/src/context/wallet_lifecycle/tests.rs index 8c0b7edbb..9b1198fe2 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1181,17 +1181,26 @@ async fn shielded_receive_address_is_none_before_bind() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn cache_shielded_receive_address_publishes_bound_account_zero_address() { let (ctx, sender, _tmp) = offline_testnet_context(); - ctx.ensure_wallet_backend(sender) - .await - .expect("ensure_wallet_backend should succeed offline"); let seed = [0xD7u8; 64]; let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) .expect("build wallet"); let seed_hash = wallet.seed_hash(); + // Register BEFORE wiring the backend: with no backend yet, + // `register_wallet_upstream` finds none and skips the fire-and-forget + // `wallet_upstream_registration` subtask. Wiring first would spawn that + // subtask, which then races the explicit `ensure_upstream_registered` + // below — both call `create_wallet_from_seed_bytes`, the loser sees + // `WalletAlreadyExists` then `get_wallet` returns `None` in the insert gap + // → `WalletNotFound` (reliably under CI load). This ordering makes + // `ensure_upstream_registered` the single upstream writer. ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) .expect("register wallet"); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); // Mirror `bootstrap_wallet_addresses_jit`'s ordering: a wallet must be // registered upstream before its Orchard keys can bind. @@ -1231,17 +1240,23 @@ async fn cache_shielded_receive_address_publishes_bound_account_zero_address() { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn remove_wallet_evicts_shielded_receive_address() { let (ctx, sender, _tmp) = offline_testnet_context(); - ctx.ensure_wallet_backend(sender) - .await - .expect("ensure_wallet_backend should succeed offline"); let seed = [0xE9u8; 64]; let wallet = crate::model::wallet::Wallet::new_from_seed(seed, Network::Testnet, None, None) .expect("build wallet"); let seed_hash = wallet.seed_hash(); + // Register BEFORE wiring the backend so `register_wallet_upstream` skips the + // fire-and-forget `wallet_upstream_registration` subtask; otherwise it races + // the explicit `ensure_upstream_registered` below (both call + // `create_wallet_from_seed_bytes`; the loser hits `WalletAlreadyExists` then + // a `None` `get_wallet` in the insert gap → `WalletNotFound` under CI load). ctx.register_wallet(wallet, &seed, WalletOrigin::Fresh) .expect("register wallet"); + ctx.ensure_wallet_backend(sender) + .await + .expect("ensure_wallet_backend should succeed offline"); + let backend = ctx.wallet_backend().expect("backend wired"); // Mirror `bootstrap_wallet_addresses_jit`'s ordering: a wallet must be // registered upstream before its Orchard keys can bind. diff --git a/tests/kittest/contract_screen.rs b/tests/kittest/contract_screen.rs index 235694d9b..d7c195c1a 100644 --- a/tests/kittest/contract_screen.rs +++ b/tests/kittest/contract_screen.rs @@ -48,8 +48,7 @@ fn mount_context() -> ( .with_animations(false) }); harness.set_size(egui::vec2(1280.0, 800.0)); - harness.run_steps(5); - let ctx = harness.state().current_app_context().clone(); + let ctx = crate::support::wait_for_wallet_backend(&mut harness); (harness, ctx) } diff --git a/tests/kittest/dashpay_screen.rs b/tests/kittest/dashpay_screen.rs index b440f5b1f..501fb4a0c 100644 --- a/tests/kittest/dashpay_screen.rs +++ b/tests/kittest/dashpay_screen.rs @@ -106,8 +106,7 @@ fn build_ctx() -> ( .expect("AppState builds") .with_animations(false) }); - h.run_steps(5); - let ctx = h.state().current_app_context().clone(); + let ctx = crate::support::wait_for_wallet_backend(&mut h); (h, ctx) } diff --git a/tests/kittest/register_dpns_name_screen.rs b/tests/kittest/register_dpns_name_screen.rs index 422757050..1a96ec1c8 100644 --- a/tests/kittest/register_dpns_name_screen.rs +++ b/tests/kittest/register_dpns_name_screen.rs @@ -123,8 +123,7 @@ fn dpns_registration_defaults_to_app_scoped_identity() { .expect("AppState builds") .with_animations(false) }); - harness.run_steps(5); - let app_context = harness.state().current_app_context().clone(); + let app_context = crate::support::wait_for_wallet_backend(&mut harness); let _first = seed_identity_for_dpns(&app_context, 0x11, "DPNS Alpha"); let second = seed_identity_for_dpns(&app_context, 0x22, "DPNS Beta"); diff --git a/tests/kittest/support.rs b/tests/kittest/support.rs index a77405eb0..974286e9a 100644 --- a/tests/kittest/support.rs +++ b/tests/kittest/support.rs @@ -7,9 +7,44 @@ use dash_evo_tool::context::AppContext; use dash_evo_tool::ui::RootScreenType; use egui_kittest::Harness; use std::sync::Arc; +use std::time::{Duration, Instant}; pub use data_dir::with_isolated_data_dir; +/// Upper bound a mount helper waits for the wallet backend to finish wiring. +/// Generous on purpose: the poll runs under whole-suite CPU/swap contention +/// (dozens of parallel tests), where a fixed frame count races the async init +/// and intermittently panics `WalletBackendNotYetWired`. +const WALLET_BACKEND_WIRE_TIMEOUT: Duration = Duration::from_secs(30); + +/// Step `harness` until its live `AppContext` has a wired wallet backend, then +/// return that context. Panics if the backend is not wired within +/// [`WALLET_BACKEND_WIRE_TIMEOUT`]. +/// +/// `AppState::new` spawns wallet-backend wiring as a background tokio task, so a +/// fixed `run_steps(N)` gives no guarantee it has completed. Tests that seed the +/// DB via `insert_local_qualified_identity` (which reaches through the backend's +/// k/v store) must gate on this instead of a fixed step count to close the race +/// deterministically — `wallet_backend().is_ok()` is the exact precondition that +/// seeding needs. +pub fn wait_for_wallet_backend( + harness: &mut Harness<'static, dash_evo_tool::app::AppState>, +) -> Arc { + let deadline = Instant::now() + WALLET_BACKEND_WIRE_TIMEOUT; + loop { + harness.step(); + let ctx = harness.state().current_app_context().clone(); + if ctx.wallet_backend().is_ok() { + return ctx; + } + assert!( + Instant::now() < deadline, + "wallet backend was not wired within {WALLET_BACKEND_WIRE_TIMEOUT:?}" + ); + std::thread::sleep(Duration::from_millis(20)); + } +} + /// Mounts the full `AppState` on `root_screen` and steps the frame loop until /// it settles. Skips the app's first-run welcome screen so the requested root /// screen renders directly. Owns a private tokio runtime for the duration of @@ -32,6 +67,7 @@ pub fn mount_app(root_screen: RootScreenType) -> Harness<'static, dash_evo_tool: app }); harness.set_size(egui::vec2(1280.0, 800.0)); + wait_for_wallet_backend(&mut harness); harness.run_steps(10); harness } @@ -48,8 +84,7 @@ pub fn fresh_app_context() -> (tokio::runtime::Runtime, Arc) { .expect("Failed to create AppState") .with_animations(false) }); - bootstrap.run_steps(5); - let app_context = bootstrap.state().current_app_context().clone(); + let app_context = wait_for_wallet_backend(&mut bootstrap); drop(bootstrap); drop(guard); (rt, app_context) diff --git a/tests/kittest/tokens_screen.rs b/tests/kittest/tokens_screen.rs index d2fb9e9b7..d341fdb9c 100644 --- a/tests/kittest/tokens_screen.rs +++ b/tests/kittest/tokens_screen.rs @@ -39,8 +39,7 @@ fn build_ctx() -> ( .expect("AppState builds") .with_animations(false) }); - h.run_steps(5); - let ctx = h.state().current_app_context().clone(); + let ctx = crate::support::wait_for_wallet_backend(&mut h); (h, ctx) } diff --git a/tests/kittest/tools_screen.rs b/tests/kittest/tools_screen.rs index 9e210062a..8139d63dc 100644 --- a/tests/kittest/tools_screen.rs +++ b/tests/kittest/tools_screen.rs @@ -47,8 +47,7 @@ fn build_ctx() -> ( .expect("AppState builds") .with_animations(false) }); - h.run_steps(5); - let ctx = h.state().current_app_context().clone(); + let ctx = crate::support::wait_for_wallet_backend(&mut h); (h, ctx) } From 1d61b22cdad2140ca3080ea7270c97fb660cd99f Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:31:03 +0000 Subject: [PATCH 27/30] test(wallets): pin the pill-click mirroring seam on the Wallets page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/ui/wallets/wallets_screen/mod.rs | 44 ++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index 1945669e6..6a09c7fef 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -3431,6 +3431,50 @@ mod tests { assert_eq!(screen.selected_wallet_seed_hash(), Some(second)); } + /// Why this page uses the *capturing* top-panel variant: the shared applier + /// the panel runs on a pill click moves only the **app-global** selection. + /// This page caches its own wallet handle, so without mirroring the effect + /// back into that cache the pill and the page body would disagree until the + /// user navigated away and returned — the arrival re-sync cannot help, since + /// a pill click performs no navigation. + /// + /// Pins the seam between the two halves of the wallet-selector linking: drop + /// `apply_nav_effect` from the `ui()` path and the second assertion here is + /// what the user would experience — a click that moves the pill but not the + /// page. + #[test] + fn a_pill_click_must_be_mirrored_into_the_page_cache() { + let (ctx, _tmp) = offline_ctx(); + let first = seed_hd_wallet(&ctx, 0xAA); + let second = seed_hd_wallet(&ctx, 0xBB); + let mut screen = WalletsBalancesScreen::new(&ctx); + screen.select_hd_wallet_by_hash(first); + + // Exactly what the top panel does on a pill click, before the page gets + // a say: the shared applier writes the app-global selection. + let effect = GlobalNavEffect::SwitchWallet(second); + crate::ui::components::top_panel::apply_global_nav_effect(&ctx, effect.clone()); + + assert_eq!( + ctx.selected_wallet_hash(), + Some(second), + "the applier moves the app-global selection" + ); + assert_eq!( + screen.selected_wallet_seed_hash(), + Some(first), + "...but it does NOT touch this page's cached wallet — the page is still on the old one" + ); + + // The mirroring step this page owns is what closes that gap, in-frame. + screen.apply_nav_effect(effect); + assert_eq!( + screen.selected_wallet_seed_hash(), + Some(second), + "the page now shows the wallet the pill shows" + ); + } + /// A wallet switched from another page's nav pill while this screen was away /// is adopted on arrival — otherwise the pill and the page would disagree. #[test] From b56ebffdd8b69acbd01d003238e98e36bb02c1ad Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:23:19 +0000 Subject: [PATCH 28/30] 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 --- src/backend_task/migration/finish_unwire.rs | 29 ++++- src/database/legacy_import.rs | 137 +++++++++++++++++++- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/src/backend_task/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 3fd6a0880..274297843 100644 --- a/src/backend_task/migration/finish_unwire.rs +++ b/src/backend_task/migration/finish_unwire.rs @@ -364,19 +364,35 @@ pub async fn run(app_context: &Arc) -> Result { return Err(identity_error); } }; - let app_data = app_data?; - - let moved_data = wallet_moved || app_data.moved_data() || identities.moved_data(); // Identities outrank votes when both are damaged: an identity that did not // come across took its keys with it, so the user cannot sign — let alone // vote — until it is loaded again. Both counts are logged; one banner shows. + // + // This check runs BEFORE `app_data` is unwrapped: a hard app-data failure is + // deterministic (one malformed vote-index blob recurs every launch, since its + // sentinel is never written), so unwrapping `app_data?` first would return + // that error and permanently mask this "reload your identity" banner. The + // app-data error is logged here and retries next launch; it must not take + // precedence over unreadable keys. if identities.unreadable > 0 { + let (app_data_moved, votes_unreadable) = match &app_data { + Ok(outcome) => (outcome.moved_data(), outcome.votes_unreadable), + Err(app_data_error) => { + tracing::warn!( + target = "migration::finish_unwire", + error = ?app_data_error, + "App-data import failed on the same launch as unreadable legacy identities; it retries on the next launch", + ); + (false, 0) + } + }; + let moved_data = wallet_moved || app_data_moved || identities.moved_data(); tracing::warn!( target = "migration::finish_unwire", unreadable = identities.unreadable, imported = identities.imported, - votes_unreadable = app_data.votes_unreadable, + votes_unreadable, network = ?app_context.network, "Some legacy identities could not be decoded; they stay in the previous version's data.db and must be loaded again", ); @@ -386,6 +402,11 @@ pub async fn run(app_context: &Arc) -> Result { return Ok(moved_data); } + // Every identity decoded, so app-data no longer masks anything critical: a + // hard failure here is the user's "Retry now" banner. + let app_data = app_data?; + let moved_data = wallet_moved || app_data.moved_data() || identities.moved_data(); + // The warning is read back from storage rather than taken from this pass's // counters: on every launch after the discovery run the import short-circuits // on its sentinel and reports zero, yet the votes it could not decode still diff --git a/src/database/legacy_import.rs b/src/database/legacy_import.rs index 617c8b4f2..e613f83a6 100644 --- a/src/database/legacy_import.rs +++ b/src/database/legacy_import.rs @@ -14,6 +14,7 @@ use std::collections::BTreeMap; use dash_sdk::dpp::dashcore::Network; +use dash_sdk::dpp::identity::accessors::IdentityGettersV0; use dash_sdk::dpp::platform_value::string_encoding::Encoding; use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; use dash_sdk::platform::Identifier; @@ -294,7 +295,7 @@ pub(crate) fn read_identities( } let mut stmt = conn.prepare( - "SELECT id, data, status, wallet, wallet_index FROM identity \ + "SELECT id, data, status, wallet, wallet_index, alias FROM identity \ WHERE is_local = 1 AND data IS NOT NULL AND network IN (?1, ?2)", )?; let mut rows = stmt.query(rusqlite::params![ @@ -315,6 +316,9 @@ pub(crate) fn read_identities( let status: i64 = row.get(2)?; let wallet: Option> = row.get(3)?; let wallet_index: Option = row.get(4)?; + // Denormalised copy of the blob's own alias. Read as a fallback only — + // the blob wins whenever it carries one (see below). + let alias: Option = row.get(5)?; let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) else { tracing::warn!( @@ -389,12 +393,36 @@ pub(crate) fn read_identities( continue; }; + // The vault key derives from the id inside the blob, not this row's `id` + // column (see `insert_local_qualified_identity`), while the migration's + // skip-if-present precheck keys off the column. A hand-edited row whose + // two ids disagree would pass that precheck and then silently overwrite a + // different, already-loaded identity. Treat the divergence as row-level + // corruption: count it and skip, like every other bad row here. + if qi.identity.id().to_buffer() != id { + tracing::warn!( + target = "database::legacy_import", + identity = %hex::encode(id), + embedded = %hex::encode(qi.identity.id().to_buffer()), + "Skipping legacy identity whose row id and stored id disagree", + ); + out.unreadable = out.unreadable.saturating_add(1); + continue; + } + // Neither field is in the bincode blob — the legacy encoder skipped both // and kept them in columns. Without this, every imported identity reads // back as `Unknown` status on mainnet. qi.status = IdentityStatus::from(status); qi.network = network; + // The blob is the source of truth for the alias; the column is a + // denormalised copy. Fall back to it only when the blob carries none, so + // a row that has both always keeps the blob's value. + if qi.alias.is_none() { + qi.alias = alias; + } + out.identities.push(LegacyIdentityRow { id, qi, wallet }); } @@ -1028,6 +1056,12 @@ mod tests { /// A genuinely-encodable identity blob, in the legacy `to_bytes()` shape. fn identity_blob(id: [u8; 32]) -> Vec { + identity_blob_with_alias(id, Some("alias")) + } + + /// Like [`identity_blob`], but with an explicit alias — `None` yields a blob + /// whose own alias is absent, which exercises the column fallback. + fn identity_blob_with_alias(id: [u8; 32], alias: Option<&str>) -> Vec { use crate::model::qualified_identity::IdentityType; use dash_sdk::dpp::version::PlatformVersion; @@ -1042,7 +1076,7 @@ mod tests { associated_operator_identity: None, associated_owner_key_id: None, identity_type: IdentityType::User, - alias: Some("alias".to_string()), + alias: alias.map(str::to_string), private_keys: Default::default(), dpns_names: vec![], associated_wallets: BTreeMap::new(), @@ -1291,6 +1325,105 @@ mod tests { assert_eq!(read.unreadable, 2, "both corrupt rows are reported"); } + /// The blob is the source of truth for the alias; the `alias` column is a + /// denormalised copy. The column fills in only when the blob carries none — + /// otherwise a blob with its own alias must keep it, column notwithstanding. + #[test] + fn identities_alias_falls_back_to_column_only_when_blob_has_none() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + + let blob_wins = [0xAA; 32]; + let column_fallback = [0xBB; 32]; + + // Blob carries its own alias — the differing column value must be ignored. + conn.execute( + "INSERT INTO identity (id, data, status, is_local, alias, network) + VALUES (?1, ?2, 2, 1, 'column-alias', 'testnet')", + rusqlite::params![ + blob_wins.as_slice(), + identity_blob_with_alias(blob_wins, Some("blob-alias")) + ], + ) + .unwrap(); + // Blob has no alias — the column is the only source left, so it fills in. + conn.execute( + "INSERT INTO identity (id, data, status, is_local, alias, network) + VALUES (?1, ?2, 2, 1, 'column-alias', 'testnet')", + rusqlite::params![ + column_fallback.as_slice(), + identity_blob_with_alias(column_fallback, None) + ], + ) + .unwrap(); + + let read = read_identities(&conn, Network::Testnet).unwrap(); + + let alias_of = |id: [u8; 32]| { + read.identities + .iter() + .find(|r| r.id == id) + .unwrap_or_else(|| panic!("row not found")) + .qi + .alias + .clone() + }; + assert_eq!( + alias_of(blob_wins).as_deref(), + Some("blob-alias"), + "the blob's own alias wins over the column", + ); + assert_eq!( + alias_of(column_fallback).as_deref(), + Some("column-alias"), + "the column fills in when the blob has no alias", + ); + assert_eq!(read.unreadable, 0); + } + + /// The vault key comes from the id *inside* the blob, but the migration's + /// skip-if-present precheck keys off the row's `id` column. A row whose two + /// ids disagree is corruption: importing it would silently overwrite the + /// unrelated identity the blob names. It is counted and skipped, not imported. + #[test] + fn identities_skip_rows_whose_row_id_and_blob_id_disagree() { + let conn = Connection::open_in_memory().unwrap(); + create_identity_table(&conn); + + let good = [0xAA; 32]; + let row_id = [0xBB; 32]; + let blob_id = [0xCC; 32]; + + insert_identity( + &conn, + good, + Some(identity_blob(good)), + 2, + true, + None, + "testnet", + ); + // Row `id` column and the blob's embedded id disagree. + insert_identity( + &conn, + row_id, + Some(identity_blob(blob_id)), + 2, + true, + None, + "testnet", + ); + + let read = read_identities(&conn, Network::Testnet).unwrap(); + + assert_eq!(read.identities.len(), 1, "only the consistent row imports"); + assert_eq!(read.identities[0].id, good); + assert_eq!( + read.unreadable, 1, + "the divergent row is reported, never imported", + ); + } + #[test] fn identities_absent_table_reads_empty() { let conn = Connection::open_in_memory().unwrap(); From 4fc2c768029a9694fd048077c1da104d0b9485eb Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:32:58 +0000 Subject: [PATCH 29/30] feat(edition): add Edition type and masternode-owner-edition feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a compile-time application edition selected by the new `masternode-owner-edition` Cargo feature and read via `Edition::CURRENT`. `Edition::allows` is the pure per-`RootScreenType` screen policy; `Edition::permits` composes it with `UserRole` so the Developer role is a full escape hatch. Home/floor screens back the navigation clamp added next. A UX restriction only — not a security or binary-size boundary; the feature does not dead-code-eliminate hidden screens. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WVUVpGxaAsciGSiT7t4kuC --- Cargo.toml | 4 + src/model/edition.rs | 188 +++++++++++++++++++++++++++++++++++++++++++ src/model/mod.rs | 1 + 3 files changed, 193 insertions(+) create mode 100644 src/model/edition.rs diff --git a/Cargo.toml b/Cargo.toml index 36365d591..2754b158f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -107,6 +107,10 @@ raw-cpuid = "11.5.0" [features] default = [] testing = [] +# Masternode-owner edition: a stripped-down build that exposes only the +# Masternodes screen and Settings. A runtime UX restriction, not a security or +# binary-size boundary. See docs/ai-design/2026-07-13-masternode-owner-edition/. +masternode-owner-edition = [] bench = [] mcp = ["dep:rmcp", "rmcp/server", "rmcp/macros", "rmcp/transport-streamable-http-server", "dep:axum", "dep:subtle"] cli = ["dep:rmcp", "rmcp/server", "rmcp/macros", "rmcp/client", "rmcp/transport-io", "rmcp/transport-streamable-http-client-reqwest", "dep:clap", "dep:clap_complete"] diff --git a/src/model/edition.rs b/src/model/edition.rs new file mode 100644 index 000000000..9080dc45f --- /dev/null +++ b/src/model/edition.rs @@ -0,0 +1,188 @@ +//! The build-time application **edition** — which surfaces the compiled binary +//! exposes. Selected once at compile time (the `masternode-owner-edition` Cargo +//! feature) and read through [`Edition::CURRENT`]. +//! +//! An edition is a *UX restriction*, not a security or binary-size boundary: the +//! Cargo feature does not dead-code-eliminate the hidden screens (they stay +//! enum-reachable and compiled), and hiding a screen does not stop the +//! background subsystems (shielded coordinator, event bridge, identity sweeps) +//! that boot regardless of navigation. The edition only decides which root +//! screens are *reachable* through the navigation. +//! +//! The policy is a single pure predicate — [`Edition::allows`] — over +//! [`RootScreenType`], composed with the user's [`UserRole`] at the enforcement +//! sites via [`Edition::permits`] (the Developer role is a full escape hatch). +//! Design: `docs/ai-design/2026-07-13-masternode-owner-edition/design.md`. + +use crate::model::settings::RootScreenType; +use crate::model::user_role::UserRole; + +/// Which edition this binary is. Chosen at compile time; read via +/// [`Edition::CURRENT`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Edition { + /// The complete application — every screen is reachable (gated only by the + /// role/capability system, never by the edition). + Full, + /// A stripped-down build for masternode owners performing an urgent upgrade: + /// only the Masternodes screen and Settings are reachable. Everything else + /// is hidden from navigation. + MasternodeOwner, +} + +impl Edition { + /// The edition this binary was compiled as. + #[cfg(feature = "masternode-owner-edition")] + pub const CURRENT: Edition = Edition::MasternodeOwner; + + /// The edition this binary was compiled as. + #[cfg(not(feature = "masternode-owner-edition"))] + pub const CURRENT: Edition = Edition::Full; + + /// Whether this edition exposes `screen` at all — the pure edition policy, + /// independent of the user's role. [`Edition::Full`] exposes everything; + /// [`Edition::MasternodeOwner`] exposes only the Masternodes screen and + /// Settings (the network chooser). + /// + /// This is the single source of truth for the edition's scope. It carries no + /// role logic on purpose: the Developer escape hatch is applied by + /// [`permits`](Self::permits), keeping this predicate pure and unit-testable. + pub fn allows(self, screen: RootScreenType) -> bool { + match self { + Edition::Full => true, + Edition::MasternodeOwner => matches!( + screen, + RootScreenType::RootScreenMasternodes | RootScreenType::RootScreenNetworkChooser + ), + } + } + + /// Whether `screen` is reachable given this edition **and** the user's + /// `role`. The Developer role is a full escape hatch: it lifts the edition + /// restriction entirely, so every screen becomes reachable again (subject to + /// its own feature gate, evaluated separately at the callsite). + pub fn permits(self, screen: RootScreenType, role: UserRole) -> bool { + self.allows(screen) || role.at_least(UserRole::Developer) + } + + /// The screen this edition prefers to land on when a persisted or requested + /// target is not reachable — before falling back to + /// [`always_reachable_screen`](Self::always_reachable_screen). + pub fn home_screen(self) -> RootScreenType { + match self { + Edition::Full => RootScreenType::RootScreenIdentities, + Edition::MasternodeOwner => RootScreenType::RootScreenMasternodes, + } + } + + /// A screen this edition **always** permits and that carries no further + /// feature gate — a guaranteed-reachable floor for the clamp so navigation + /// can never strand the user on a hidden screen. Settings stays reachable in + /// the masternode-owner edition precisely so a user who has dropped below + /// Power can still get back to the role selector. + pub fn always_reachable_screen(self) -> RootScreenType { + match self { + Edition::Full => RootScreenType::RootScreenIdentities, + Edition::MasternodeOwner => RootScreenType::RootScreenNetworkChooser, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every root screen there is — kept in sync with `RootScreenType::from_int` + /// so the policy is exercised against the whole surface, not a sample. + fn all_root_screens() -> Vec { + (0..=64).filter_map(RootScreenType::from_int).collect() + } + + #[test] + fn full_edition_allows_every_screen() { + for screen in all_root_screens() { + assert!( + Edition::Full.allows(screen), + "Full edition must expose {screen:?}" + ); + } + } + + #[test] + fn masternode_owner_allows_only_masternodes_and_settings() { + for screen in all_root_screens() { + let expected = matches!( + screen, + RootScreenType::RootScreenMasternodes | RootScreenType::RootScreenNetworkChooser + ); + assert_eq!( + Edition::MasternodeOwner.allows(screen), + expected, + "MasternodeOwner edition scope for {screen:?}" + ); + } + } + + #[test] + fn developer_role_is_a_full_escape_hatch() { + // Below Developer: only the edition's own scope is permitted. + for role in [UserRole::Everyday, UserRole::Power] { + assert!( + !Edition::MasternodeOwner.permits(RootScreenType::RootScreenWalletsBalances, role), + "a hidden screen must stay hidden at {role:?}" + ); + } + // Developer lifts the restriction entirely. + for screen in all_root_screens() { + assert!( + Edition::MasternodeOwner.permits(screen, UserRole::Developer), + "Developer must reach {screen:?} (escape hatch)" + ); + } + } + + #[test] + fn permits_keeps_the_edition_scope_below_developer() { + // The two in-scope screens are permitted at any role; the escape hatch is + // not the only way to reach them. + for role in [UserRole::Everyday, UserRole::Power, UserRole::Developer] { + assert!(Edition::MasternodeOwner.permits(RootScreenType::RootScreenMasternodes, role)); + assert!( + Edition::MasternodeOwner.permits(RootScreenType::RootScreenNetworkChooser, role) + ); + } + } + + #[test] + fn full_edition_permits_everything_regardless_of_role() { + for screen in all_root_screens() { + assert!(Edition::Full.permits(screen, UserRole::Everyday)); + } + } + + #[test] + fn fallback_screens_are_always_reachable_in_their_edition() { + // The always-reachable floor must be in the edition's own `allows` scope + // (so the escape hatch is never load-bearing for the fallback) at every + // role, including the lowest. + for edition in [Edition::Full, Edition::MasternodeOwner] { + let floor = edition.always_reachable_screen(); + assert!( + edition.allows(floor), + "{edition:?} floor {floor:?} must be in the edition scope" + ); + assert!( + edition.permits(floor, UserRole::Everyday), + "{edition:?} floor {floor:?} must be reachable at the lowest role" + ); + } + } + + #[test] + fn current_edition_matches_the_compiled_feature() { + #[cfg(feature = "masternode-owner-edition")] + assert_eq!(Edition::CURRENT, Edition::MasternodeOwner); + #[cfg(not(feature = "masternode-owner-edition"))] + assert_eq!(Edition::CURRENT, Edition::Full); + } +} diff --git a/src/model/mod.rs b/src/model/mod.rs index a020542b3..ec2771669 100644 --- a/src/model/mod.rs +++ b/src/model/mod.rs @@ -4,6 +4,7 @@ pub mod contested_name; pub mod dashpay; pub mod dashpay_derivation; pub mod dpns; +pub mod edition; pub mod fee_estimation; pub mod grovestark_prover; pub mod identity_discovery; From 638b67f218cf62b07d767db01aa3299c3d132551 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Mon, 13 Jul 2026 18:33:06 +0000 Subject: [PATCH 30/30] feat(edition): gate navigation and force first-run role for the masternode-owner edition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforce edition/role screen reachability through one predicate (`root_screen_reachable`) at the single navigation chokepoint (`set_main_screen`), plus `active_root_screen_mut`, the initial-selection resolver, and the left-nav table. Generalises the prior Masternodes de-gate into that predicate — no behaviour change in the Full build. All screens stay registered so the Developer escape hatch reveals them immediately and the `active_root_screen_mut` lookup can never panic. Land the masternode-owner edition on Power the first time it runs (`apply_edition_first_run_role`) so its Power-gated Masternodes surface is reachable; first-run only, never overriding an explicit prior choice, and safe against a settings read failure. Settings stays always-reachable as the recovery path. Adds unit tests (both builds), the design record, and user story MN-013. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01WVUVpGxaAsciGSiT7t4kuC --- .../design.md | 247 ++++++++++++++++++ docs/user-stories.md | 10 + src/app.rs | 185 +++++++++++-- src/context/settings_db.rs | 158 +++++++++++ src/ui/components/left_panel.rs | 8 + 5 files changed, 591 insertions(+), 17 deletions(-) create mode 100644 docs/ai-design/2026-07-13-masternode-owner-edition/design.md diff --git a/docs/ai-design/2026-07-13-masternode-owner-edition/design.md b/docs/ai-design/2026-07-13-masternode-owner-edition/design.md new file mode 100644 index 000000000..48d2bbcee --- /dev/null +++ b/docs/ai-design/2026-07-13-masternode-owner-edition/design.md @@ -0,0 +1,247 @@ +# Masternode-Owner Edition — Design + +**Status:** Implemented (PR against #885 base `feat/legacy-identity-migration`). +**Date:** 2026-07-13 +**Depends on:** PR #879 (persona-capability gating: `UserRole`, `FeatureGate`, `Check`) — **verified landed** in the base branch (see §1). +**Motivating trigger:** Masternode owners performing an urgent v0.9.3 → v1.0 upgrade need a stripped-down build that surfaces only the withdrawal-capable Masternodes screen and Settings, with everything else hidden — a smaller, less error-prone surface for a one-off, time-pressured task. + +This document formalises the feature and records the verification findings and the +deviations from the originally captured (chat-only, uncommitted) design, per the +instruction to *verify every claim against the actual code before implementing*. + +--- + +## 1. Verification of the dependency (#879) + +`feat/legacy-identity-migration` **already contains** the persona-capability gating: + +- `src/model/user_role.rs` — `UserRole { Everyday < Power < Developer }`, `UserRoleCell`. +- `src/context/feature_gate.rs` — `Check::{MinRole, Capability, Experimental}`, `Capability`, + `ExperimentalFeature`, `FeatureGate::{Shielded, ShieldedOperations, DashPay, + DashPayOperations, Masternodes, DeveloperTools}`. +- `docs/ai-design/2026-07-10-persona-capability-gating/design.md`. + +So this edition builds directly on it; nothing from #879 had to be pulled in. + +Two facts from that landed code materially shaped this design: + +1. **`FeatureGate::Masternodes = &[Check::MinRole(UserRole::Power)]`** — the Masternodes + surface requires Power. This is the load-bearing constraint the edition must satisfy. +2. **`UserRole::WHEN_UNSET = Power`** — a fresh install *or* a legacy blob that never + recorded a role resolves to **Power**, not Everyday (`with_default_user_role` / + `seed_user_role_from_settings` in `context/settings_db.rs`). This **supersedes the + originally captured "brick risk #3" premise** ("#879 defaults fresh installs to + Everyday"): a fresh masternode-owner-edition install already lands on Power and already + sees the Masternodes screen. The first-run force (§4) is therefore about *durability and + explicitness*, not about rescuing a fresh install from a brick — the landed default + already prevents that. + +--- + +## 2. Scope + +- **Reachable in the masternode-owner edition:** `RootScreenMasternodes` and + `RootScreenNetworkChooser` (Settings) only. +- **Hidden:** every other `RootScreenType` (Identities, Wallets, Tokens, DashPay, Tools, + Contracts, …). +- **Escape hatch:** the **Developer** role lifts the edition restriction entirely — all + screens become reachable again. This is a first-class requirement, not a side effect, and + it constrains the enforcement design (§3). +- **Enabling fact (verified):** masternode load is wallet-free — + `src/ui/masternodes/load_form.rs` sets `derive_keys_from_wallets: false` and documents + "masternode keys never live in a wallet's HD tree". A masternode owner never needs to + create or import a wallet, which is why hiding the entire Wallets surface does not break + the core flow. + +### Non-goals / honest limitations + +- **Not a security or resource boundary.** Hiding navigation does **not** stop background + subsystems (shielded coordinator, event bridge, DashPay detection, identity-discovery + sweeps) — they boot regardless of which nav entries are visible. This edition does not + gate them (out of scope) and makes no attack-surface/resource claim. +- **Not dead-code elimination.** The Cargo feature does **not** remove the hidden screens + from the binary; they stay enum-reachable and compiled. The gating is a runtime UX + restriction only. + +--- + +## 3. Mechanism + +### 3.1 `Edition` (`src/model/edition.rs`) + +```rust +pub enum Edition { Full, MasternodeOwner } +``` + +- `Edition::CURRENT` — selected at compile time by the `masternode-owner-edition` Cargo + feature (`MasternodeOwner` when set, else `Full`). +- `Edition::allows(RootScreenType) -> bool` — the **single, pure** screen policy. `Full` + allows everything; `MasternodeOwner` allows only Masternodes + NetworkChooser. No role + logic (kept pure and unit-testable). +- `Edition::permits(RootScreenType, UserRole) -> bool` — `allows(screen) || role ≥ + Developer`. This is where the **Developer escape hatch** composes with the edition axis. +- `Edition::home_screen()` / `always_reachable_screen()` — the preferred landing and the + guaranteed-reachable floor for the navigation clamp (§3.3). + +### 3.2 Composition with the role system — deviation from `Check::Edition` + +The captured design asked for a `Check::Edition(Edition)` variant in `feature_gate.rs`. +**Not added — a reasoned deviation:** + +- Screen visibility is a **`RootScreenType`-level** concern. The `Check`/`FeatureGate` + system is **feature-level** (it answers "may this role/network use feature X?"), and + carries no screen identity. There is no natural feature-level consumer for an edition + check. +- The Developer escape hatch reveals **all screens**, so there is likewise no *feature* + that should be edition-restricted — an edition-gated feature would contradict the escape + hatch. +- An enum variant that is never constructed fails the repo's `-D warnings` gate + (`dead_code`). Adding `Check::Edition` with no consumer would not compile clean. + +Instead, the edition composes with the role axis exactly where it belongs — at the +screen-reachability boundary — via `Edition::permits(screen, role)`. This honours the +"compose the edition into the existing role gating" intent without minting a dead variant, +and keeps the pure edition policy (`allows`) separate from the runtime role (`permits`). + +### 3.3 Enforcement — three points, one predicate + +A single private predicate in `app.rs` funnels all reachability decisions: + +```rust +fn root_screen_reachable(ctx, screen) -> bool { + Edition::CURRENT.permits(screen, ctx.user_role()) // edition + escape hatch + && match screen { // per-screen feature gate + RootScreenMasternodes => FeatureGate::Masternodes.is_available(ctx), + _ => true, + } +} +fn edition_landing(ctx) -> RootScreenType { // clamp target, always reachable + let home = Edition::CURRENT.home_screen(); + if root_screen_reachable(ctx, home) { home } else { Edition::CURRENT.always_reachable_screen() } +} +``` + +This **generalises the pre-existing Masternodes de-gate** (the old special-case that +bounced Masternodes → Identities when the role dropped below Power) into one predicate. In +the `Full` build the edition axis is always satisfied, so `root_screen_reachable` reduces +to exactly the old Masternodes gating — **no behaviour change** (locked by +`edition_nav_tests::full_edition_keeps_all_screens_reachable`). + +Enforcement sites: + +1. **`AppState::set_main_screen()`** — the chokepoint every `SetMainScreen*` action passes + through (verified: all `SetMainScreen`, `…ThenPopScreen`, `…ThenGoToMainScreen` handlers + call it, including #882's global nav-pill path `top_panel.rs` → + `GlobalNavEffect::NavigateToRoot`). A request for an unreachable screen is clamped to + `edition_landing`, and the **clamped** target is persisted so the next boot reopens on a + reachable screen. +2. **`AppState::active_root_screen_mut()`** — live de-gating: if the active screen becomes + unreachable (role dropped, or edition-hidden), clamp before the `get_mut(...).expect()`. + Because `edition_landing` always returns a registered, reachable screen, the `expect` + can never fire (addresses the panic risk flagged for this method). +3. **Initial selection (`AppState::new`)** — the persisted screen is honoured only if it is + both registered *and* reachable; otherwise it clamps to `edition_landing`. Prevents the + first frame opening on a hidden screen (e.g. a persisted Tokens tab). +4. **Left-nav table (`ui/components/left_panel.rs`)** — nav entries the current edition does + not `permit` are skipped (defense in depth; combined with the existing per-entry + `FeatureGate` filter). Not the sole gate — a nav-filter-only approach was rejected + because non-nav navigation paths (buttons, global pills) would bypass it. + +### 3.4 `main_screens` registration is NOT edition-filtered — deviation + +The captured design asked to filter screen *registration* so hidden screens "are never +constructed at all". **Not done — a reasoned deviation**, because it is **mutually +exclusive with the Developer escape hatch**: an escaped Developer must be able to switch +into every screen, which requires the screens to exist in `main_screens`. Conditionally +constructing them by boot-time role would break the escape hatch on a runtime Power → +Developer switch (screens wouldn't exist until restart), and would reintroduce the exact +`active_root_screen_mut().expect()` panic risk (a fallback to an unregistered screen). The +captured design's own risk #2 confirms not constructing screens buys **no** dead-code or +security benefit. So all screens are constructed as before; reachability is enforced purely +at the four points in §3.3. This is strictly safer (the `expect` cannot panic) and makes +the escape hatch correct and immediate. + +--- + +## 4. First-run role forcing + +`AppContext::apply_edition_first_run_role()` (`context/settings_db.rs`), called once at boot +right after `seed_user_role_from_settings()`: + +- **No-op in every edition except `MasternodeOwner`** (compile-time `Edition::CURRENT` + check; dead-code-eliminated in the `Full` build). +- **First-run only:** it reads the *raw* persisted role (pre-`WHEN_UNSET` resolution). It + acts **only when no role was ever recorded** (`None` on disk — a fresh or pre-role + install), setting and persisting `Power`. An **explicit prior choice is never + overridden** — Developer (the escape hatch) and any other recorded role are respected + ("no migration from any prior flag/value; persists normally after that"). +- **Read-failure safe:** a k/v read error is *not* mistaken for "first run"; it leaves the + boot seed in charge (mirroring `seed_user_role_from_settings`'s caution), so a transient + glitch can never silently rewrite the user's real role. + +Relationship to `WHEN_UNSET = Power` (§1): on a truly fresh install the runtime role is +*already* Power via the seed, so this force's observable effect there is to **record** Power +durably (turning an implicit default into an explicit, single-source-of-truth value). It is +the explicit, spec'd first-run behaviour and is independently testable. The persisted-Power +value also means a subsequent `Full`-build run on the same data dir resolves deterministically. + +**Corner cases and the safety net.** A user who *explicitly* picked Everyday (only possible +by choosing it in Settings), or a boot where the settings read failed, can leave the edition +showing **only Settings** (Masternodes needs Power). This is **not a hard brick**: Settings +(the network chooser) is always reachable and hosts the role selector, so the user can +restore Power/Developer themselves. The always-reachable floor (`always_reachable_screen = +NetworkChooser`) guarantees this recovery path. + +--- + +## 5. Files touched + +| File | Change | +|---|---| +| `Cargo.toml` | new `masternode-owner-edition` feature | +| `src/model/edition.rs` | **new** — `Edition`, `CURRENT`, `allows`/`permits`/`home_screen`/`always_reachable_screen` + unit tests | +| `src/model/mod.rs` | `pub mod edition;` | +| `src/context/settings_db.rs` | `apply_edition_first_run_role()` + first-run tests | +| `src/app.rs` | `root_screen_reachable` / `edition_landing`; clamp at `set_main_screen`, `active_root_screen_mut`, initial selection; boot call; nav-clamp tests | +| `src/ui/components/left_panel.rs` | edition filter in the nav loop | + +--- + +## 6. Testing + +- `model::edition::tests` — `allows`/`permits`/escape-hatch/floor/`CURRENT` (both variants + exercised in the default build). +- `app::edition_nav_tests` — `root_screen_reachable` / `edition_landing`; `Full` build + proves no behaviour change, `masternode-owner-edition` build proves the clamp. +- `context::settings_db::edition_first_run_tests` — first-run lands on Power & persists; + explicit Everyday/Developer not overridden; idempotent (does not re-fire). + +**Feature note for CI/reviewers:** the edition-specific assertions are behind +`#[cfg(feature = "masternode-owner-edition")]`, so `cargo test` must be run **twice** to +cover both paths: + +```bash +cargo test --lib # Full build (default) +cargo test --lib --features masternode-owner-edition # edition build +``` + +Both were run green during implementation. Full `--all-features --all-targets` clippy and +the complete suite are deferred to the independent QA pass per the coordinator's scope +guidance; the library compiles clean under both `--features testing` (default) and +`--features masternode-owner-edition,testing`. + +--- + +## 7. Deviations summary (for the reviewer) + +1. **No `Check::Edition` variant.** Screen visibility is `RootScreenType`-level, orthogonal + to the feature-level `Check` system; a variant with no consumer fails `-D warnings`. The + edition composes with the role axis via `Edition::permits` at the screen boundary + instead. (§3.2) +2. **`main_screens` registration not filtered.** Mutually exclusive with the Developer + escape hatch (screens must exist to switch into); no dead-code/security benefit; avoids + the `active_root_screen_mut` `expect` panic. Enforcement is at nav + `set_main_screen` + + `active_root_screen_mut` + initial selection. (§3.4) +3. **Brick risk #3 premise superseded.** Landed `WHEN_UNSET = Power` already prevents the + fresh-install brick; the first-run force is retained for durability/explicitness and to + satisfy the explicit spec, keyed on "no role ever recorded". (§1, §4) diff --git a/docs/user-stories.md b/docs/user-stories.md index 067c5602a..b53c5bba8 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -1544,3 +1544,13 @@ As a masternode operator, I want the same page-scoped switcher on the Masternode - The Masternodes header renders the page-aware breadcrumb with an interactive wallet pill (the funding source for Top Up), which two-way binds with the page's wallet context. - The third segment is a page-scoped node pill listing every loaded masternode/evonode, two-way bound with the page: opening a card names that node on the pill, and picking a node from the pill opens its detail view. It reads `(no masternode yet)` when none is loaded and `(choose a masternode)` while the grid is open. - Picking a node there never changes the identity shown on the everyday-user pages (see MN-005's Identity Hub filter) — the node selection is page-scoped, never the app-global identity. + +### MN-013: Run the masternode-owner edition for an urgent upgrade [Implemented] +**Persona:** Priya + +As a masternode operator doing an urgent v0.9.3 → v1.0 upgrade, I want a stripped-down build that shows only the Masternodes screen and Settings, so that I can complete the withdrawal path without navigating a full application I do not need for this task. + +- A compile-time `masternode-owner-edition` build exposes only the Masternodes screen and Settings; every other root screen is hidden from navigation. All navigation paths (nav list, global pills, in-screen buttons) are clamped at a single chokepoint, so no path reaches a hidden screen. +- The build lands on the Detailed view (Power) on first run so the Masternodes screen is available immediately; a later explicit choice is respected. +- Selecting the Developer view is a full escape hatch that reveals every screen again. Settings always stays reachable, so the interface-mode selector is never stranded. +- The masternode load path is wallet-free, so no wallet needs to be created or imported in this edition. diff --git a/src/app.rs b/src/app.rs index 67bcfffa5..e632e663e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,6 +16,7 @@ use crate::context::connection_status::{ConnectionStatus, OverallConnectionState use crate::context::feature_gate::FeatureGate; use crate::context::migration_status::MigrationStep; use crate::database::Database; +use crate::model::edition::Edition; use crate::model::settings::AppSettings; use crate::ui::components::secret_prompt_host::{ActivePrompt, EguiSecretPromptHost, QueuedPrompt}; use crate::ui::components::{BannerHandle, MessageBanner, OptionBannerExt, ProgressOverlay}; @@ -525,6 +526,40 @@ impl BackendInitReason { } } +/// Whether `screen` is reachable right now — the single reachability predicate +/// the navigation clamp funnels through. +/// +/// Two axes compose here: the build [`Edition`] (with the Developer escape +/// hatch, via [`Edition::permits`]) decides whether the screen is exposed at +/// all, and the per-screen feature gate decides whether the current role/context +/// may use it. Today only the Masternodes screen carries such a gate +/// ([`FeatureGate::Masternodes`], Power-and-up); every other screen has none. +/// +/// In the [`Edition::Full`] build the edition axis is always satisfied, so this +/// reduces to exactly the pre-existing Masternodes gating — no behaviour change. +fn root_screen_reachable(app_context: &AppContext, screen: RootScreenType) -> bool { + Edition::CURRENT.permits(screen, app_context.user_role()) + && match screen { + RootScreenType::RootScreenMasternodes => { + FeatureGate::Masternodes.is_available(app_context) + } + _ => true, + } +} + +/// The screen navigation clamps to when the requested or persisted target is not +/// reachable. Prefers the edition's home screen, falling back to its guaranteed +/// always-reachable floor — so the result is always a registered, reachable +/// screen and the `active_root_screen_mut` lookup below can never panic. +fn edition_landing(app_context: &AppContext) -> RootScreenType { + let home = Edition::CURRENT.home_screen(); + if root_screen_reachable(app_context, home) { + home + } else { + Edition::CURRENT.always_reachable_screen() + } +} + impl AppState { /// Creates a new `AppState`, opening the seed vault keyless. /// @@ -691,6 +726,11 @@ impl AppState { // `seed_user_role_from_settings`. active_context.seed_user_role_from_settings(); + // A masternode-owner edition build lands on Power on first run so its + // Power-gated Masternodes surface is reachable; a no-op elsewhere. Must + // follow the seed above (it reads/publishes the same role cell). + active_context.apply_edition_first_run_role(); + // load fonts ctx.set_fonts(crate::bundled::fonts().expect("failed to load fonts")); @@ -930,13 +970,18 @@ impl AppState { }) .collect(); - // Resolve the effective selected root screen. If the persisted value - // is no longer registered, fall back to the `Identities` screen so - // `active_root_screen_mut()` does not panic on first frame. - let selected_main_screen = if main_screens.contains_key(&persisted_main_screen) { + // Resolve the effective selected root screen. Keep the persisted value + // only if it is both registered and reachable in the current edition and + // role; otherwise clamp to the edition's landing screen. This guarantees + // the first frame opens on a reachable screen and never on one the + // edition hides (e.g. a persisted Tokens tab in the masternode-owner + // edition). + let selected_main_screen = if main_screens.contains_key(&persisted_main_screen) + && root_screen_reachable(&active_context, persisted_main_screen) + { persisted_main_screen } else { - RootScreenType::RootScreenIdentities + edition_landing(&active_context) }; let mut app_state = Self { @@ -1113,14 +1158,13 @@ impl AppState { } pub fn active_root_screen_mut(&mut self) -> &mut Screen { - // Live de-gating (§10.11): if the role dropped below Power while the - // Masternodes tab was active, fall back to the neutral Identities tab so - // the gated screen is never shown without its gate. Identities is always - // registered, so the subsequent lookup cannot fail. - if self.selected_main_screen == RootScreenType::RootScreenMasternodes - && !FeatureGate::Masternodes.is_available(self.current_app_context()) - { - self.selected_main_screen = RootScreenType::RootScreenIdentities; + // Live de-gating: if the active screen has become unreachable — the role + // dropped below a screen's gate, or (in a restricted edition) the screen + // is hidden — clamp to the edition's landing screen so a gated or hidden + // screen is never shown. `edition_landing` always returns a registered, + // reachable screen, so the subsequent lookup cannot fail. + if !root_screen_reachable(self.current_app_context(), self.selected_main_screen) { + self.selected_main_screen = edition_landing(self.current_app_context()); } self.main_screens .get_mut(&self.selected_main_screen) @@ -1338,11 +1382,20 @@ impl AppState { } fn set_main_screen(&mut self, root_screen_type: RootScreenType) { - self.selected_main_screen = root_screen_type; + // The single funnel every `SetMainScreen*` action passes through — the + // one enforcement chokepoint for edition/role reachability. A request for + // an unreachable screen (a hidden edition tab, or a gated screen the + // current role cannot use) is clamped to the edition's landing screen so + // no navigation path can bypass the gate. Persist the clamped target, not + // the requested one, so the next boot reopens on a reachable screen. + let target = if root_screen_reachable(self.current_app_context(), root_screen_type) { + root_screen_type + } else { + edition_landing(self.current_app_context()) + }; + self.selected_main_screen = target; self.active_root_screen_mut().refresh_on_arrival(); - self.current_app_context() - .update_settings(root_screen_type) - .ok(); + self.current_app_context().update_settings(target).ok(); } /// Auto-start chain sync for the active context when the user opted in. @@ -2064,3 +2117,101 @@ mod spv_overlay_tests { } } } + +/// Reachability clamp — the navigation gate `set_main_screen` / +/// `active_root_screen_mut` funnel through. `Edition::CURRENT` is compile-time, +/// so each build asserts its own edition's behaviour under a `cfg`. +#[cfg(test)] +mod edition_nav_tests { + use super::*; + use crate::context::test_support::test_app_context; + use crate::model::user_role::UserRole; + + fn ctx_with_role(role: UserRole) -> (tempfile::TempDir, std::sync::Arc) { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + ctx.set_user_role(role); + (tmp, ctx) + } + + /// Masternodes is Power-gated in every edition; the gate is unchanged by the + /// edition work. + #[test] + fn masternodes_needs_power_in_any_edition() { + for (role, expected) in [ + (UserRole::Everyday, false), + (UserRole::Power, true), + (UserRole::Developer, true), + ] { + let (_tmp, ctx) = ctx_with_role(role); + assert_eq!( + root_screen_reachable(&ctx, RootScreenType::RootScreenMasternodes), + expected, + "Masternodes reachability for {role:?}" + ); + } + } + + /// Full build (default features): the edition axis is always satisfied, so + /// every non-Masternodes screen stays reachable at every role — no + /// behaviour change from the pre-edition code. + #[cfg(not(feature = "masternode-owner-edition"))] + #[test] + fn full_edition_keeps_all_screens_reachable() { + for role in [UserRole::Everyday, UserRole::Power, UserRole::Developer] { + let (_tmp, ctx) = ctx_with_role(role); + for screen in [ + RootScreenType::RootScreenWalletsBalances, + RootScreenType::RootScreenMyTokenBalances, + RootScreenType::RootScreenNetworkChooser, + RootScreenType::RootScreenIdentities, + ] { + assert!( + root_screen_reachable(&ctx, screen), + "Full edition must keep {screen:?} reachable for {role:?}" + ); + } + // The landing screen is Identities and it is always reachable. + assert_eq!(edition_landing(&ctx), RootScreenType::RootScreenIdentities); + } + } + + /// Masternode-owner edition: only Masternodes + Settings are reachable below + /// Developer; Developer is a full escape hatch; and the clamp always lands on + /// a reachable screen (never stranding the user). + #[cfg(feature = "masternode-owner-edition")] + #[test] + fn masternode_owner_edition_hides_everything_but_masternodes_and_settings() { + // Settings stays reachable at every role — the recovery path. + for role in [UserRole::Everyday, UserRole::Power, UserRole::Developer] { + let (_tmp, ctx) = ctx_with_role(role); + assert!(root_screen_reachable( + &ctx, + RootScreenType::RootScreenNetworkChooser + )); + } + + // Below Developer, a hidden screen is unreachable; Developer lifts it. + let hidden = RootScreenType::RootScreenWalletsBalances; + let (_t1, everyday) = ctx_with_role(UserRole::Everyday); + let (_t2, power) = ctx_with_role(UserRole::Power); + let (_t3, developer) = ctx_with_role(UserRole::Developer); + assert!(!root_screen_reachable(&everyday, hidden)); + assert!(!root_screen_reachable(&power, hidden)); + assert!( + root_screen_reachable(&developer, hidden), + "Developer escape hatch must reveal hidden screens" + ); + + // Landing: Power lands on Masternodes; Everyday (Masternodes gated out) + // falls back to the always-reachable Settings floor. + assert_eq!( + edition_landing(&power), + RootScreenType::RootScreenMasternodes + ); + assert_eq!( + edition_landing(&everyday), + RootScreenType::RootScreenNetworkChooser + ); + } +} diff --git a/src/context/settings_db.rs b/src/context/settings_db.rs index 96635350c..99a3aee25 100644 --- a/src/context/settings_db.rs +++ b/src/context/settings_db.rs @@ -7,6 +7,7 @@ //! stale value. use super::{AppContext, SettingsCacheGuard}; +use crate::model::edition::Edition; use crate::model::settings::{AppSettings, detect_dash_qt_path}; use crate::model::user_role::UserRole; use crate::ui::RootScreenType; @@ -201,6 +202,56 @@ impl AppContext { self.set_user_role(role); } + /// Land a masternode-owner edition build on [`UserRole::Power`] the first + /// time it runs, so its only meaningful surface — the Power-gated + /// Masternodes screen — is reachable out of the box. + /// + /// A no-op in every other edition. In the masternode-owner edition it fires + /// **only on first run**: when no role has ever been persisted (a fresh or + /// pre-role install). An explicit prior choice is never overridden — a user + /// who picked Developer (the edition's escape hatch) or any other role keeps + /// it. Once this records `Power`, the disk holds an explicit role and the + /// force never fires again ("persists normally after that"). + /// + /// Deliberately keyed on a role that was **never recorded**, distinct from + /// one that could not be **read**: a transient k/v read failure must not be + /// mistaken for "first run" and silently rewrite the user's real role. On a + /// read error this does nothing and leaves the boot seed + /// ([`seed_user_role_from_settings`](Self::seed_user_role_from_settings)) in + /// charge. + /// + /// Runs once per process, at boot, after the role has been seeded. + pub fn apply_edition_first_run_role(&self) { + if Edition::CURRENT != Edition::MasternodeOwner { + return; + } + // Read the raw persisted role (pre-resolution): `Some(None)` is "a blob + // exists but recorded no role", `None` is "no blob yet" — both are first + // run; `Some(Some(_))` is an explicit prior choice to respect. + match self + .app_kv + .get::(DetScope::Global, AppSettings::KV_KEY) + { + Ok(Some(settings)) if settings.user_role.is_some() => {} // explicit choice — keep it + Ok(_) => { + if let Err(e) = self.set_and_persist_user_role(UserRole::Power) { + tracing::warn!( + error = ?e, + "Could not record the initial interface mode for this edition; \ + the Masternodes screen may be hidden until a role is picked in Settings." + ); + } + } + Err(e) => { + tracing::warn!( + error = ?e, + "Could not read the stored interface mode at startup; leaving the \ + seeded role in place for this edition." + ); + } + } + } + /// Load and decode [`AppSettings`] straight from the k/v store, applying the /// dash-qt autodetect and default-role fallbacks. Bypasses the cache — the /// caller must hold the `cached_settings` write lock. @@ -815,4 +866,111 @@ mod tests { assert_eq!(ctx.user_role(), UserRole::Power); assert_eq!(ctx.get_app_settings().user_role, Some(UserRole::Power)); } + + /// In a non-edition (Full) build the first-run force is a no-op: it must not + /// touch the role, so a Full build never silently promotes anyone. + #[cfg(not(feature = "masternode-owner-edition"))] + #[test] + fn first_run_force_is_a_no_op_outside_the_edition() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + ctx.set_user_role(UserRole::Everyday); + + ctx.apply_edition_first_run_role(); + + assert_eq!(ctx.user_role(), UserRole::Everyday); + // Nothing was persisted either. + assert_eq!( + ctx.app_kv() + .get::(DetScope::Global, AppSettings::KV_KEY) + .unwrap() + .and_then(|s| s.user_role), + None, + ); + } +} + +/// First-run role-forcing behaviour of the masternode-owner edition. These only +/// have teeth when compiled as that edition (`Edition::CURRENT` is a no-op +/// otherwise), so the whole module is feature-gated. +#[cfg(all(test, feature = "masternode-owner-edition"))] +mod edition_first_run_tests { + use super::*; + use crate::context::test_support::test_app_context; + + /// The raw persisted role, before the `WHEN_UNSET` resolution — `None` means + /// no explicit choice was ever recorded. + fn persisted_role(ctx: &AppContext) -> Option { + ctx.app_kv() + .get::(DetScope::Global, AppSettings::KV_KEY) + .unwrap() + .and_then(|s| s.user_role) + } + + /// First run (no role ever recorded) lands on Power and records it, so the + /// Power-gated Masternodes surface is reachable out of the box and the force + /// never fires again. + #[test] + fn first_run_lands_on_power_and_persists_it() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + assert_eq!(persisted_role(&ctx), None, "no role recorded yet"); + + ctx.apply_edition_first_run_role(); + + assert_eq!(ctx.user_role(), UserRole::Power); + assert_eq!( + persisted_role(&ctx), + Some(UserRole::Power), + "the force must record an explicit role so it fires only once" + ); + } + + /// An explicit prior choice is never overridden — the force is first-run + /// only. A user who dropped to Everyday keeps it (recoverable via Settings). + #[test] + fn explicit_everyday_choice_is_not_overridden() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + ctx.set_and_persist_user_role(UserRole::Everyday).unwrap(); + + ctx.apply_edition_first_run_role(); + + assert_eq!(ctx.user_role(), UserRole::Everyday); + assert_eq!(persisted_role(&ctx), Some(UserRole::Everyday)); + } + + /// The Developer escape hatch survives the force untouched. + #[test] + fn developer_escape_hatch_is_preserved() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + ctx.set_and_persist_user_role(UserRole::Developer).unwrap(); + + ctx.apply_edition_first_run_role(); + + assert_eq!(ctx.user_role(), UserRole::Developer); + } + + /// Idempotent: a second boot (role now recorded as Power) leaves an + /// Everyday runtime role alone — proving the force keys on the persisted + /// value, not the live one, and cannot re-fire. + #[test] + fn force_does_not_refire_once_a_role_is_recorded() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_app_context(tmp.path()); + ctx.apply_edition_first_run_role(); // records Power + assert_eq!(persisted_role(&ctx), Some(UserRole::Power)); + + // Simulate a later, lower runtime role (as if the user picked Everyday + // this session but it is already recorded as Power on disk). + ctx.set_user_role(UserRole::Everyday); + ctx.apply_edition_first_run_role(); + + assert_eq!( + ctx.user_role(), + UserRole::Everyday, + "an already-recorded role must not be re-forced" + ); + } } diff --git a/src/ui/components/left_panel.rs b/src/ui/components/left_panel.rs index 1b0b41ea3..6d6ed9199 100644 --- a/src/ui/components/left_panel.rs +++ b/src/ui/components/left_panel.rs @@ -1,6 +1,7 @@ use crate::app::AppAction; use crate::context::AppContext; use crate::context::feature_gate::FeatureGate; +use crate::model::edition::Edition; use crate::model::user_role::UserRole; use crate::ui::RootScreenType; use crate::ui::components::icons::{load_icon, load_svg_icon}; @@ -144,6 +145,13 @@ pub fn add_left_panel( .show(ui, |ui| { ui.vertical_centered(|ui| { for (label, screen_type, icon_path, gate) in buttons.iter() { + // Skip entries the current edition hides (the + // Developer role lifts this — see `Edition::permits`). + if !Edition::CURRENT + .permits(*screen_type, app_context.user_role()) + { + continue; + } // Skip entries whose feature gate is not available if let Some(gate) = gate && !gate.is_available(app_context)