diff --git a/CHANGELOG.md b/CHANGELOG.md index 480f07353..3f21e57a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,9 +128,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 @@ -148,9 +156,25 @@ 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 +- **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/Cargo.toml b/Cargo.toml index a4b485422..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"] @@ -151,6 +155,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/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-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-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-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. | 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/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/docs/user-stories.md b/docs/user-stories.md index 11ad015b9..b53c5bba8 100644 --- a/docs/user-stories.md +++ b/docs/user-stories.md @@ -273,6 +273,32 @@ As a multi-wallet user, I can switch the active wallet from the top-nav pill whi - A single-key selection made in the tab survives navigation; a later explicit HD pick from the pill supersedes it — the two selection surfaces never show different wallets. - With a single wallet the pill has nothing to switch to and stays effectively non-interactive. +### WAL-029: View and copy my shielded receive address [Implemented] +**Persona:** Jordan + +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 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-030: 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-031: Single-key wallet balance and UTXOs update automatically [Gap] +**Persona:** Priya, Jordan + +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. + +- 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. + --- ## Send and Receive (SND) @@ -290,7 +316,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-031: 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 @@ -398,6 +425,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) @@ -634,6 +679,24 @@ 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 [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. +- 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. + --- ## DashPay (DPY) @@ -706,6 +769,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] @@ -742,6 +808,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) @@ -887,7 +963,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. --- @@ -1153,6 +1230,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 Detailed view, Developer tools, 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 [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. +- 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. + --- ## Programmatic Access (MCP) @@ -1232,6 +1359,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] @@ -1285,6 +1420,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] @@ -1372,3 +1527,30 @@ 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 [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. +- 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 01da72257..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}; @@ -58,6 +59,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 @@ -128,14 +135,43 @@ 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.", MigrationStep::WalletMeta => "Updating wallet names.", + MigrationStep::Identities => "Restoring your identities and their keys.", MigrationStep::Finalize => "Finishing storage update.", } } +/// 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." + ) +} + +/// 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. /// @@ -490,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. /// @@ -545,6 +615,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(), @@ -639,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")); @@ -878,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 { @@ -1061,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) @@ -1286,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. @@ -1808,10 +1913,12 @@ 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, MigrationStep::WalletMeta, + MigrationStep::Identities, MigrationStep::Finalize, ] { let text = migration_running_text(step); @@ -1830,10 +1937,12 @@ 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), 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(); @@ -2008,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/app/reconcilers.rs b/src/app/reconcilers.rs index f3dbab683..8b08bdce1 100644 --- a/src/app/reconcilers.rs +++ b/src/app/reconcilers.rs @@ -31,8 +31,9 @@ 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_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_identities_text, migration_unreadable_votes_text, should_dispatch_cold_start, spv_block_step, }; @@ -474,6 +475,35 @@ impl MigrationReconciler { ); self.banner_handle = Some(handle); } + MigrationState::SucceededWithUnreadableVotes { count } => { + // The wallets landed; only the corrupt vote rows did not. A + // 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::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 @@ -520,9 +550,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, @@ -542,6 +573,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/core/mod.rs b/src/backend_task/core/mod.rs index d3dac6f4d..d61a1d51f 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/backend_task/dashpay.rs b/src/backend_task/dashpay.rs index 787a8ab5d..bc0ee2cdd 100644 --- a/src/backend_task/dashpay.rs +++ b/src/backend_task/dashpay.rs @@ -19,10 +19,26 @@ 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; -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 { @@ -81,6 +97,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, }, @@ -96,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 { @@ -201,6 +230,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/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_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/contact_requests.rs b/src/backend_task/dashpay/contact_requests.rs index 755e0d9e3..ac15ef7c7 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. @@ -29,6 +31,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( @@ -37,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: {}", @@ -45,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 @@ -67,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(), @@ -110,52 +100,112 @@ 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 + // 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, + |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, + }, + ); + tracing::info!( "After filtering: {} incoming, {} outgoing contact requests", incoming.len(), outgoing.len() ); - Ok(BackendTaskSuccessResult::DashPayContactRequests { incoming, outgoing }) + Ok(BackendTaskSuccessResult::DashPayContactRequests { + identity: identity_id, + incoming, + outgoing, + }) +} + +/// 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_declined: impl Fn(&Identifier) -> bool, + is_withdrawn: impl Fn(&Identifier) -> bool, +) { + incoming.retain(|(_, doc)| !is_declined(&doc.owner_id())); + outgoing.retain(|(_, doc)| match contact_request_recipient(doc) { + Some(to) => !is_withdrawn(&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); + } + contact_request_recipient(doc).ok_or_else(|| DashPayError::InvalidDocument { + reason: "contact request document is missing its toUserId field".to_string(), + }) +} + +/// 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( @@ -227,11 +277,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 { @@ -548,16 +594,32 @@ 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 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 resolution markers failed; an earlier declined or \ + withdrawn request involving this person may stay hidden", + ); + } } Ok(BackendTaskSuccessResult::DashPayContactRequestSent( @@ -635,30 +697,20 @@ 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? .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 = 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 { @@ -779,25 +831,20 @@ 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? .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; @@ -813,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" ); } @@ -836,3 +884,512 @@ pub async fn reject_contact_request( request_id, )) } + +/// 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. + /// + /// # 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 +/// 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 = contact_request_query(self.app_context)?; + 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) -> Result<(), TaskError> { + self.app_context + .wallet_backend()? + .dashpay_mark_withdrawn(&self.owner_id, &self.to_identity_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. 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 owner_id = identity.identity.id(); + + // 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_with_id = + DocumentQuery::with_document_id(contact_request_query(app_context)?, &request_id); + + let doc = Document::fetch(sdk, query_with_id) + .await? + .ok_or(TaskError::DocumentNotFound)?; + + // Verify we sent it, and to whom. + let to_identity_id = recipient_of_sent_request(&doc, &owner_id)?; + + let ops = PlatformCancelOps { + app_context, + sdk, + identity, + owner_id, + to_identity_id, + }; + + 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), + ), + } +} + +#[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 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) — 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)))), + ]; + + 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 withdrawn 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, |_| true); + + assert_eq!( + outgoing.len(), + 1, + "a request we cannot attribute must stay visible rather than be silently hidden" + ); + } + + #[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 + // 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, + /// When set, recording the withdrawal in the sidecar fails. + withdraw_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) -> Result<(), TaskError> { + if self.withdraw_fails { + return Err(TaskError::WalletBackendNotYetWired); + } + *self.withdrawn.lock().expect("not poisoned") = true; + Ok(()) + } + } + + #[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" + ); + } + + #[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 08750a2de..3d8f51c8e 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; @@ -106,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; @@ -154,11 +159,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 +169,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 +201,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") @@ -464,9 +447,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 @@ -531,9 +515,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 8b1de0d51..178c823b4 100644 --- a/src/backend_task/dashpay/errors.rs +++ b/src/backend_task/dashpay/errors.rs @@ -97,6 +97,23 @@ 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, + + /// 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/migration/finish_unwire.rs b/src/backend_task/migration/finish_unwire.rs index 01e704eca..274297843 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 @@ -38,14 +41,23 @@ 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`, `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 /// per-network sentinel key returned by [`sentinel_key_for`]. @@ -149,6 +161,47 @@ pub enum MigrationError { failed: u32, }, + /// The decoded scheduled votes could not be written into the k/v store. + /// 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] + 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 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 @@ -206,19 +259,185 @@ 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. +/// +/// 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. +/// 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. +/// +/// **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. /// -/// Drains single-key wallet rows, HD wallet seeds, and wallet metadata -/// into the upstream store, registers the migrated wallets, then writes -/// the completion sentinel. +/// 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 +/// sentinel stays unwritten, so the next launch retries), or when the wallet +/// 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(); + + // 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 = 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. 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); + + // 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); + } + }; + + // 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, + 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); + } + + // 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 + // 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 = 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: warning.count, + }); + 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; @@ -236,10 +455,6 @@ 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); } @@ -255,10 +470,6 @@ 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); } @@ -307,12 +518,22 @@ 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) } +/// 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 +569,484 @@ 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)) +} + +/// 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). +/// +/// 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 +/// +/// [`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); + + let done = app_kv + .get::(DetScope::Global, &sentinel_key) + .map_err(|source| MigrationError::Sentinel { source })? + .is_some(); + if done { + 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(AppDataMigrationOutcome::default()); + }; + if !path.exists() { + return Ok(AppDataMigrationOutcome::default()); + } + 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_completion_sentinel(&app_kv, &sentinel_key)?; + return Ok(AppDataMigrationOutcome::default()); + } + + // 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, + votes_unreadable = outcome.votes_unreadable, + top_up_identities_imported = outcome.top_up_identities_imported, + network = ?network, + "App-data migration pass complete", + ); + + // Persist the warning before the sentinel: this is the only pass that ever + // counts the undecodable rows (the sentinel short-circuits every later + // launch), so the count has to outlive it or the user gets one banner and no + // second chance. Ordered before the sentinel so a crash in between re-runs the + // idempotent import rather than losing the warning. + write_vote_warning(&app_kv, network, outcome.votes_unreadable)?; + + // 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_completion_sentinel(&app_kv, &sentinel_key)?; + + Ok(outcome) +} + +/// Record one import pass as complete for one network. Shared by the app-data +/// and identity sentinels, which reuse the [`MigrationCompletion`] payload so +/// every sentinel has the same codec. +fn write_completion_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, so a failure never blocks the votes that + // already landed — every identity is attempted before the pass gives up. But it + // is not swallowed either: the caller withholds the app-data sentinel on `Err`, + // so the next launch retries the idempotent import. Swallowing would freeze a + // one-off k/v error into permanent loss, because the sentinel short-circuits + // every later launch. Undecodable *rows* never reach here — the reader skips + // and logs them — so an error here is structural and a retry is worth taking. + let failure = match crate::database::legacy_import::read_top_ups(conn, network) { + Ok(top_ups) => { + 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) { + 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 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(source) => Some(MigrationError::LegacyDbRead { + table: "top_up", + source, + }), + }; + if let Some(failure) = failure { + return Err(failure); + } + + 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. @@ -403,6 +1102,32 @@ fn table_has_rows(conn: &Connection, table: &'static str) -> Result 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. @@ -790,12 +1515,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. @@ -1363,33 +2084,519 @@ 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() { + // ── App-data import: scheduled votes + top-up history ──────────── + + mod app_data { + use super::*; use dash_sdk::dpp::dashcore::Network; + use dash_sdk::dpp::voting::vote_choices::resource_vote_choice::ResourceVoteChoice; + use dash_sdk::platform::Identifier; + use std::cell::RefCell; + use std::collections::{BTreeMap, BTreeSet}; + + const VOTER: [u8; 32] = [0x11u8; 32]; + + /// A legacy `data.db` holding one scheduled vote and one top-up for a + /// testnet identity — the v0.10-dev shape. + fn legacy_conn() -> 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 + } - 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"); + /// 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 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_is_counted_without_failing_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 votes = 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(()) + }, + |_, _| Ok(()), + ) + .expect("an unreadable vote must not fail the import"); + + 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 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() { + 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")); + } + } + + // ── 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")); + } - // 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)); + /// 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 @@ -2953,6 +4160,304 @@ 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; + } + + /// 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/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..d66fb53c7 100644 --- a/src/backend_task/migration/mod.rs +++ b/src/backend_task/migration/mod.rs @@ -17,8 +17,14 @@ use crate::context::AppContext; use crate::context::migration_status::MigrationState; 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 @@ -29,6 +35,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 { @@ -66,6 +77,10 @@ impl AppContext { Err(task_error) } }, + MigrationTask::AcknowledgeUnreadableVotes => { + finish_unwire::acknowledge_unreadable_votes(self)?; + Ok(BackendTaskSuccessResult::Refresh) + } } } } 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/backend_task/migration/v093_upgrade.rs b/src/backend_task/migration/v093_upgrade.rs new file mode 100644 index 000000000..69987cb43 --- /dev/null +++ b/src/backend_task/migration/v093_upgrade.rs @@ -0,0 +1,1492 @@ +//! 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::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, 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, IdentityKeyView, 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 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. +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, +} + +/// 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. +/// +/// 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"); + + // 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 + (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, + crate::model::user_role::UserRoleCell::default(), + ) + .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) +} + +/// 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. + 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 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, + 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", + ); + + // ── 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 +/// 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"); + + // …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"), + 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 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 + .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"); + let identities: i64 = conn + .query_row("SELECT COUNT(*) FROM identity", [], |r| r.get(0)) + .expect("count identity rows"); + assert_eq!( + (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; +} + +/// 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** +/// 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/backend_task/mod.rs b/src/backend_task/mod.rs index b579a0d38..3e1257088 100644 --- a/src/backend_task/mod.rs +++ b/src/backend_task/mod.rs @@ -223,16 +223,26 @@ 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 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/backend_task/tokens/mod.rs b/src/backend_task/tokens/mod.rs index f1288fce4..1ada2be23 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), @@ -482,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 { @@ -538,13 +530,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..b83b69237 100644 --- a/src/backend_task/tokens/query_my_token_balances.rs +++ b/src/backend_task/tokens/query_my_token_balances.rs @@ -1,13 +1,18 @@ //! 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; 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; @@ -26,14 +31,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) @@ -42,37 +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(pair)?; + // 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![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, 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). + /// 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.remove_token_balance(token_id, identity_id)?; + self.mark_token_balance_untracked(pair)?; + self.remove_token_balance(pair)?; sender .send(TaskResult::Refresh) .await @@ -80,25 +84,48 @@ 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(&IdentityTokenIdentifier { + identity_id, + token_id: *token_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 +136,158 @@ 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 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, + crate::model::user_role::UserRoleCell::default(), + ) + .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) + } + + 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] + 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(pair(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(pair(identity, alpha), f.sender.clone()) + .await + .expect("stop tracking"); + f.ctx + .clear_untracked_token_balance(pair(identity, alpha)) + .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(pair(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..4631a24a2 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,37 @@ 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"; +/// 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!( "{}{}", @@ -374,20 +406,54 @@ 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, - 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 identity-token pair the user stopped tracking. + pub fn untracked_token_balances( + &self, + ) -> std::result::Result, TaskError> { + read_untracked(&self.det_kv()?) + } + + /// 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, + pair: IdentityTokenIdentifier, + ) -> std::result::Result<(), TaskError> { + mark_untracked_in(&self.det_kv()?, pair) + } + + /// 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, + pair: IdentityTokenIdentifier, + ) -> std::result::Result<(), TaskError> { + clear_untracked_in(&self.det_kv()?, pair) + } + + /// 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 +495,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,19 +663,15 @@ 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(()); } 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)?; @@ -659,6 +722,73 @@ fn decode_token_config(bytes: &[u8]) -> std::result::Result std::result::Result, TaskError> { + 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()?, + }) +} + +/// 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, + pair: IdentityTokenIdentifier, +) -> std::result::Result<(), TaskError> { + 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. +fn clear_untracked_token_in( + kv: &DetKv, + token_id: &Identifier, +) -> std::result::Result<(), TaskError> { + 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(()) +} + /// 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> @@ -692,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())) @@ -818,4 +949,243 @@ mod tests { kv.get(DetScope::Global, TOKEN_ORDER_KEY).unwrap().unwrap(); assert_eq!(got, payload); } + + // ---------------------------------------------------------------- + // 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(); + 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)]); + dismiss_all(&kv, pairs.iter().copied()); + assert_eq!(read_untracked(&kv).unwrap(), pairs); + } + + #[test] + fn dismissing_the_same_pair_twice_is_idempotent() { + let kv = empty_kv(); + dismiss_all(&kv, [pair(1, 10), pair(1, 10)]); + assert_eq!(read_untracked(&kv).unwrap(), BTreeSet::from([pair(1, 10)])); + } + + #[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!( + 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] + fn clearing_a_token_drops_its_dismissals_for_every_identity() { + let kv = empty_kv(); + let cleared = ident(1); + let kept = pair(2, 10); + dismiss_all(&kv, [pair(1, 10), pair(1, 11), kept]); + + 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([pair(1, 10)]); + 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" + ); + } } diff --git a/src/context/identity_db.rs b/src/context/identity_db.rs index cd093a490..1a71ab844 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. @@ -695,6 +717,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. @@ -784,16 +826,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 +1390,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/context/migration_status.rs b/src/context/migration_status.rs index 8e03bacbd..5265ad2ee 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. @@ -35,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, } @@ -56,6 +64,20 @@ 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 }, + /// 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. @@ -76,6 +98,14 @@ 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::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) @@ -163,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/context/mod.rs b/src/context/mod.rs index 242d360f6..dff803b0b 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -153,6 +153,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 @@ -366,6 +383,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, @@ -599,6 +617,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/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/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 77987e5b4..6d6dfdc4d 100644 --- a/src/context/wallet_lifecycle/tests.rs +++ b/src/context/wallet_lifecycle/tests.rs @@ -1143,6 +1143,149 @@ 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(); + + 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. + 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(); + + 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. + 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 @@ -1466,21 +1609,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/initialization.rs b/src/database/initialization.rs index f7e4b3201..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. @@ -2749,6 +2745,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 +2821,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..e613f83a6 --- /dev/null +++ b/src/database/legacy_import.rs @@ -0,0 +1,1434 @@ +//! 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::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; +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, network_from_legacy_str, theme_mode_from_str, +}; +use crate::model::user_role::UserRole; + +/// 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`] — 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)] +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)>; + +/// 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 +/// 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); + } + // The legacy `user_mode` column only ever held the retired `UserMode` + // strings, which gated nothing and so carry no role information. They decode + // to `None` — no role was ever chosen — and the app resolves that to + // `UserRole::WHEN_UNSET`, the tier the legacy build exposed unconditionally. + if let Some(s) = value_as_string(&values, "user_mode") { + settings.user_role = UserRole::from_persisted(&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()? { + // 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!( + 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 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, alias 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)?; + // 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)?; + // 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!( + 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; + }; + + 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 + // 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; + }; + + // 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 }); + } + + 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 +/// 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, 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", + 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 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!(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")) + ); + } + + /// The legacy `user_mode` column gated nothing, so it records no role. It + /// must import as "no role chosen" (`None`) — which the app resolves to + /// [`UserRole::WHEN_UNSET`], the surface the legacy build gave every user — + /// and never as a concrete role. Seeding `Everyday` off a legacy `Beginner` + /// would silently strip capability the user already had. + #[test] + fn legacy_user_mode_imports_as_no_role_chosen() { + for legacy_mode in ["Beginner", "Advanced"] { + let conn = Connection::open_in_memory().unwrap(); + create_settings_table(&conn); + conn.execute( + "INSERT INTO settings (id, network, start_root_screen, user_mode, database_version) + VALUES (1, 'testnet', 0, ?1, 40)", + rusqlite::params![legacy_mode], + ) + .unwrap(); + + let settings = read_app_settings(&conn).unwrap().expect("settings row"); + assert_eq!( + settings.user_role, None, + "legacy user_mode {legacy_mode} must not seed a role", + ); + } + } + + /// 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"); + } + + /// 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(); + 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)])); + } + + /// 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(); + 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 { + 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; + + 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: alias.map(str::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); + } + + /// 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"); + } + + /// 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(); + let read = read_identities(&conn, Network::Testnet).unwrap(); + assert!(read.identities.is_empty()); + assert_eq!(read.unreadable, 0); + } +} diff --git a/src/database/mod.rs b/src/database/mod.rs index ca9961d22..8d8fdfe66 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"))] @@ -30,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/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/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/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/model/dashpay.rs b/src/model/dashpay.rs index f09f22622..94e6a4603 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 { @@ -18,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")] @@ -290,6 +331,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/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; diff --git a/src/model/settings.rs b/src/model/settings.rs index af2201473..1b004f6b5 100644 --- a/src/model/settings.rs +++ b/src/model/settings.rs @@ -101,6 +101,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, @@ -135,6 +136,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), @@ -312,10 +314,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); @@ -361,7 +360,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, @@ -369,6 +368,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/src/ui/components/README.md b/src/ui/components/README.md index d4b79fd2a..b70207e92 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()`. Identities, DashPay, and DPNS use subdued pills (`subdued_everyday_spec`); Wallets uses an interactive wallet pill (`wallet_only_spec`) that drives the app-global wallet selection; Masternodes uses the identity-aware capturing variant with interactive pills. 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`); 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 also returns the (already-applied) `GlobalNavEffect` so a page that *consumes* a selection can mirror it into its own state (two-way binding). Used by Wallets (`wallet_only_spec` → mirrors `SwitchWallet` into the page's cached wallet) 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 e6711df29..b0d06676c 100644 --- a/src/ui/components/global_nav_switcher.rs +++ b/src/ui/components/global_nav_switcher.rs @@ -122,11 +122,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), ) @@ -140,20 +142,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 { @@ -306,6 +326,7 @@ pub fn render( } IdentityPillScope::PageScopedObject { placeholder, + tooltip, items, selected, } => { @@ -313,6 +334,7 @@ pub fn render( ui, consumption, placeholder, + tooltip, items, *selected, dark_mode, @@ -556,26 +578,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) @@ -586,7 +627,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(); } @@ -643,33 +688,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/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) diff --git a/src/ui/components/top_panel.rs b/src/ui/components/top_panel.rs index 8177d7027..5f906b8a8 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(); @@ -354,10 +355,11 @@ pub fn subdued_everyday_spec(label: impl Into, target: RootScreenType) - PageNavSpec::unwired_everyday(label, target, TT_WALLET_UNWIRED, TT_IDENTITY_UNWIRED) } -/// A wallet-only global-nav spec with the wallet pill **interactive** -/// (`Consumed`): picking a wallet from it drives the app-global selection via -/// [`apply_global_nav_effect`]. For pages that own the wallet-selection surface -/// (e.g. Wallets), mirroring the Masternodes page's interactive wallet pill. +/// A wallet-only global-nav spec (no identity/object pill) with the wallet pill +/// **interactive** (`Consumed`): picking a wallet from it drives the app-global +/// selection via [`apply_global_nav_effect`]. For pages with no identity context +/// that own the wallet-selection surface, e.g. Wallets — FR-GLOBAL-NAV-2 rules 2 +/// and 4. pub fn wallet_only_spec(label: impl Into, target: RootScreenType) -> PageNavSpec { PageNavSpec::new(label, target).with_wallet_pill(PillConsumption::Consumed) } @@ -433,20 +435,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( @@ -458,12 +464,8 @@ 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) } #[cfg(test)] diff --git a/src/ui/dashpay/contact_details.rs b/src/ui/dashpay/contact_details.rs index a6a914bdf..85d33a7c1 100644 --- a/src/ui/dashpay/contact_details.rs +++ b/src/ui/dashpay/contact_details.rs @@ -3,6 +3,7 @@ use crate::backend_task::dashpay::DashPayTask; use crate::backend_task::{BackendTask, BackendTaskSuccessResult}; use crate::context::AppContext; use crate::context::feature_gate::FeatureGate; +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; @@ -214,7 +215,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, }, ))) } @@ -626,7 +629,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 22ae1ea02..36bfdd4a3 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; @@ -881,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!( @@ -904,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(); @@ -942,10 +957,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/dashpay/contacts_list.rs b/src/ui/dashpay/contacts_list.rs index b2e2128c8..b118a2c53 100644 --- a/src/ui/dashpay/contacts_list.rs +++ b/src/ui/dashpay/contacts_list.rs @@ -11,6 +11,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; @@ -33,6 +34,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, @@ -532,7 +545,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 @@ -566,49 +579,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(); @@ -951,13 +922,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/contacts.rs b/src/ui/identity/contacts.rs index 9bc69346d..8789fbac9 100644 --- a/src/ui/identity/contacts.rs +++ b/src/ui/identity/contacts.rs @@ -5,44 +5,68 @@ //! 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::app::{AppAction, BackendTasksExecutionMode}; use crate::backend_task::BackendTask; -use crate::backend_task::dashpay::DashPayTask; +use crate::backend_task::dashpay::{ContactData, DashPayTask}; use crate::context::AppContext; +use crate::context::feature_gate::FeatureGate; +use crate::model::dashpay::AcceptedAccounts; use crate::model::qualified_identity::QualifiedIdentity; use crate::ui::ScreenType; +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::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"; +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 +/// 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 @@ -84,7 +108,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 +125,139 @@ 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, +/// 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, 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, +) -> Option<(Identifier, RequestAction)> { + let response = card.show(ui); + ui.add_space(4.0); + + 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, + )))) } -/// 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`. +/// One-shot hydration of the tab: the contact list and the request lists. /// -/// 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, +/// 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, + ) } -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(); +/// 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, + }, } +} - /// 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 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 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, + contact_id: contact.identity_id, + nickname: contact.nickname.clone(), + note: contact.note.clone(), + is_hidden: false, + accepted_accounts: AcceptedAccounts::Preserve, } } /// 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 +309,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,146 +354,266 @@ 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. + // + // 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 +} + +/// 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: &mut ContactsState, + dark_mode: bool, +) -> AppAction { ui.add_space(12.0); - let heading_recv = 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()) }; - section_card(ui, dark_mode, &heading_recv, |ui| { - if incoming.is_empty() { + + // 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 rows.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, 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(""), + ) + .with_busy(busy); + clicked = request_row(ui, card, entry).or(clicked); } }); - // --- Active contacts section ---------------------------------------- + dispatch_request(state, identity, clicked) +} + +/// Active contacts — searchable list of established contacts, each row offering +/// a Pay affordance that opens the existing send-payment screen. Pay is an +/// experimental DashPay feature, classified identically at all four entry points +/// into [`ScreenType::DashPaySendPayment`]. +fn active_section( + ui: &mut Ui, + app_context: &Arc, + identity: &QualifiedIdentity, + state: &mut ContactsState, + dark_mode: bool, +) -> AppAction { + let mut action = AppAction::None; + let pay_available = FeatureGate::DashPayOperations.is_available(app_context); 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); - 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); - } - }, - ); - 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(12.0); - let heading_sent = if outgoing.is_empty() { - SENT_HEADING.to_string() - } else { - format!("{SENT_HEADING} · {}", outgoing.len()) - }; - section_card(ui, dark_mode, &heading_sent, |ui| { - if outgoing.is_empty() { - ui.label( - RichText::new("No outgoing requests.").color(DashColors::text_secondary(dark_mode)), - ); + let matches = state.filtered_contacts(); + if !has_contacts { + ui.label(RichText::new(NO_ACTIVE_EMPTY).color(DashColors::text_secondary(dark_mode))); + } else if matches.is_empty() { + ui.label(RichText::new(NO_SEARCH_MATCH).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); + 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| { + if !pay_available { + return; + } + 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); - // TODO(identity-hub/T29): on Cancel dispatch - // DashPayTask::CancelContactRequest (variant not yet present — - // defer until the parallel wallet-refactor wave lands). } } + + 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 |= hidden_section(ui, identity, state, dark_mode); }); - // 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 +} + +/// 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 } -/// Shorten a Base58 identity ID for display: first 8 chars + "…". -fn abbreviate_id(id: &str) -> String { - if id.len() <= 10 { - id.to_string() +/// Sent requests — one [`RequestCard`] per pending outgoing request, with +/// Cancel wired to [`DashPayTask::CancelContactRequest`]. +fn sent_section( + ui: &mut Ui, + identity: &QualifiedIdentity, + state: &mut ContactsState, + dark_mode: bool, +) -> AppAction { + ui.add_space(12.0); + + let rows = request_rows(state.outgoing(), state); + let heading = if rows.is_empty() { + SENT_HEADING.to_string() } else { - format!("{}…", &id[..8]) - } + format!("{SENT_HEADING} · {}", rows.len()) + }; + + let mut clicked = None; + section_card(ui, dark_mode, &heading, |ui| { + if rows.is_empty() { + 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, busy) in rows { + let handle = entry.counterpart_id.to_string(Encoding::Base58); + let card = RequestCard::sent(shorten_id(&handle), &handle).with_busy(busy); + clicked = request_row(ui, card, entry).or(clicked); + } + }); + + dispatch_request(state, identity, clicked) } /// Header row: title on the left, three action buttons right-aligned. @@ -565,6 +738,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 +783,43 @@ 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, + HIDDEN_EXPLAINER, + UNHIDE_TOOLTIP, + ] { assert!( line.ends_with('.'), "empty-state copy '{line}' must end with a period" @@ -609,11 +832,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 +844,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 +889,276 @@ 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. + // --------------------------------------------------------------- + // Request-row actions — the dead-button regression these rows had: + // Accept/Decline were TODO stubs and Cancel had no task at all. + // --------------------------------------------------------------- + + #[test] + 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 t28_reset_clears_load_guard() { - let mut state = ContactsState { - load_requested: true, - ..Default::default() - }; - state.reset(); + fn decline_maps_to_the_reject_task() { + let task = request_task(RequestAction::Declined, qualified_identity(id(1)), id(2)); + assert!( + matches!(task, DashPayTask::RejectContactRequest { request_id, .. } if request_id == id(2)), + "Decline must dispatch RejectContactRequest for the clicked request" + ); + } + #[test] + fn cancel_maps_to_the_cancel_task() { + let task = request_task(RequestAction::Cancelled, qualified_identity(id(1)), id(2)); assert!( - !state.load_requested, - "reset() must clear load_requested so the next render re-fires LoadContacts" + matches!(task, DashPayTask::CancelContactRequest { request_id, .. } if request_id == id(2)), + "Cancel must dispatch CancelContactRequest for the clicked request" + ); + } + + // --------------------------------------------------------------- + // 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 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:?}"), + } } // --------------------------------------------------------------- - // ContactsState cache wiring + // 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. // --------------------------------------------------------------- - /// 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, - }], + 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"); }; - state.reset(); + + assert_eq!(mode, BackendTasksExecutionMode::Concurrent); assert!( - !state.load_requested, - "reset() must clear the load guard (T28)" + 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!( - state.incoming.is_empty(), - "reset() must clear incoming cache" + 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.outgoing.is_empty(), - "reset() must clear outgoing cache" + 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" ); } - /// 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 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" + ); } - /// Received and sent section headings count the entries. #[test] - fn section_headings_include_count_when_populated() { + fn a_failed_request_becomes_clickable_again() { + let identity = qualified_identity(id(1)); 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" + 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" ); - assert_eq!(RECEIVED_HEADING, "Received requests"); + } + + #[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 = [ + 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..d3031bf2e 100644 --- a/src/ui/identity/hub_screen.rs +++ b/src/ui/identity/hub_screen.rs @@ -125,6 +125,25 @@ 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. + 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. @@ -345,15 +364,69 @@ 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 { 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 + // 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."); + } + // 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. 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(), + "You are already contacts with this person.", + MessageType::Info, + ); } _ => {} } } 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 @@ -368,6 +441,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::*; @@ -413,4 +500,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/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/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/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/masternodes/list_screen.rs b/src/ui/masternodes/list_screen.rs index c2f3fd64e..fd4906698 100644 --- a/src/ui/masternodes/list_screen.rs +++ b/src/ui/masternodes/list_screen.rs @@ -18,15 +18,17 @@ use crate::context::AppContext; use crate::model::contested_name::MasternodeContestSummary; use crate::model::qualified_identity::{IdentityStatus, IdentityType, MasternodeKeyPresence}; use crate::model::user_role::UserRole; +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}; @@ -233,6 +235,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. @@ -406,14 +446,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); @@ -547,4 +582,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/mod.rs b/src/ui/mod.rs index cabf7cb55..b6ea3c988 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -213,6 +213,11 @@ 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 entry points are gated on + /// [`FeatureGate::DashPayOperations`](crate::context::feature_gate::FeatureGate::DashPayOperations) + /// — paying a contact is an experimental DashPay operation, so no route to + /// this screen may open without it. DashPaySendPayment(QualifiedIdentity, Identifier), DashPayQRGenerator, DashPayProfileSearch, diff --git a/src/ui/network_chooser_screen.rs b/src/ui/network_chooser_screen.rs index d549055ab..4355322ea 100644 --- a/src/ui/network_chooser_screen.rs +++ b/src/ui/network_chooser_screen.rs @@ -11,7 +11,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, }; @@ -23,7 +22,6 @@ use dash_sdk::dpp::dashcore::Network; use dash_sdk::dpp::identity::TimestampMillis; use eframe::egui::{self, Ui}; use std::collections::BTreeMap; -use std::path::PathBuf; use std::sync::Arc; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -62,9 +60,6 @@ fn add_dapi_status_label( 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, selected_role: UserRole, @@ -95,19 +90,6 @@ impl NetworkChooserScreen { .next() .expect("BUG: NetworkChooserScreen requires at least one AppContext"); - 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 selected_role = current_context.user_role(); @@ -117,8 +99,6 @@ impl NetworkChooserScreen { Self { network_contexts: contexts.clone(), - data_dir, - dashmate_password_input, current_network, recheck_time: None, selected_role, @@ -198,7 +178,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, @@ -242,17 +221,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); - } }); }); diff --git a/src/ui/state/contacts_view.rs b/src/ui/state/contacts_view.rs new file mode 100644 index 000000000..dd19de22d --- /dev/null +++ b/src/ui/state/contacts_view.rs @@ -0,0 +1,697 @@ +//! 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 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}; +use std::collections::HashSet; + +/// 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 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, + /// 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 { + /// 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.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 + /// 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)| { + Some(ContactRequestEntry { + counterpart_id: contact_request_recipient(&doc)?, + request_id, + relative_time: relative_time(&doc), + }) + }) + .collect(); + } + + /// 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) { + 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 + /// `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> { + self.contacts + .iter() + .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. 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(); + } +} + +/// 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, 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; + } + [ + 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) +} + +/// 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 { + display_label( + contact.nickname.as_deref(), + contact.display_name.as_deref(), + contact.username.as_deref(), + &contact.identity_id.to_string(Encoding::Base58), + ) +} + +/// 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::*; + use crate::ui::identity::identity_pill::shorten_id; + + 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"); + } + + /// 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_keeps_hidden_contacts_out_of_the_active_list() { + let mut state = ContactsState::default(); + 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 among the active ones" + ); + assert_eq!( + state.filtered_contacts()[0].nickname.as_deref(), + Some("Bao") + ); + } + + #[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(); + 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 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!( + 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 base58 = id(7).to_string(Encoding::Base58); + let fallback = contact_label(&contact(None, None, None)); + assert_eq!(fallback, shorten_id(&base58)); + assert!(fallback.contains('…')); + } + + #[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 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) + ), + ); + } + } +} 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/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/ui/wallets/shielded_tab.rs b/src/ui/wallets/shielded_tab.rs index de7053194..62942fe43 100644 --- a/src/ui/wallets/shielded_tab.rs +++ b/src/ui/wallets/shielded_tab.rs @@ -2,12 +2,13 @@ use crate::app::AppAction; use crate::backend_task::BackendTask; use crate::backend_task::migration::MigrationTask; use crate::context::AppContext; -use crate::context::feature_gate::FeatureGate; 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}; @@ -27,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 = @@ -63,7 +75,12 @@ 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 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, } @@ -81,10 +98,12 @@ 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, - /// 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. @@ -104,7 +123,7 @@ impl ShieldedTabView { is_initialized: false, tree_synced: false, pending_task: None, - address_count: 1, + shielded_address: None, sidecar_skipped: false, } } @@ -146,7 +165,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; @@ -157,7 +178,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 { @@ -169,17 +190,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 @@ -191,29 +215,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 shielded_enabled = FeatureGate::ShieldedOperations.is_available(&self.app_context); - 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(shielded_enabled); + .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()); + } + } + } }); } @@ -696,6 +770,54 @@ mod tests { ); } + /// WAL-029 — 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] @@ -749,6 +871,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/src/ui/wallets/single_key_send_screen.rs b/src/ui/wallets/single_key_send_screen.rs index 0aad9fb5c..ae3710836 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,37 +779,18 @@ impl SingleKeyWalletSendScreen { ui.add_space(20.0); - // Send button - 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; - // 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 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(false, send_button) + .on_disabled_hover_text(SINGLE_KEY_SEND_UNAVAILABLE); if response.clicked() { match self.validate_and_send() { Ok(send_action) => { @@ -842,9 +824,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 +833,15 @@ impl ScreenLike for SingleKeyWalletSendScreen { egui::ScrollArea::vertical() .auto_shrink([true; 2]) .show(ui, |ui| { - // Persistent warning banner for the SPV backend. 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(); + // 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.send_unavailable_banner.show(ui); + ui.add_space(10.0); // Heading with Advanced Options checkbox ui.horizontal(|ui| { @@ -923,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; } } @@ -994,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, + crate::model::user_role::UserRoleCell::default(), + ) + .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" + ); + } +} diff --git a/src/ui/wallets/wallets_screen/mod.rs b/src/ui/wallets/wallets_screen/mod.rs index eacf75c08..6a09c7fef 100644 --- a/src/ui/wallets/wallets_screen/mod.rs +++ b/src/ui/wallets/wallets_screen/mod.rs @@ -19,10 +19,11 @@ 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, wallet_only_spec}; +use crate::ui::components::top_panel::{add_top_panel_with_global_nav_capturing, 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; @@ -403,6 +404,31 @@ 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); + } + } + fn select_single_key_wallet(&mut self, wallet: Arc>) { self.selected_single_key_wallet = Some(wallet.clone()); self.selected_wallet = None; @@ -1166,10 +1192,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( @@ -2468,12 +2498,17 @@ impl ScreenLike for WalletsBalancesScreen { DesiredAppAction::Custom("RefreshSKWallet".to_string()), )); } - let mut action = add_top_panel_with_global_nav( + // Capturing variant: the effect is already applied to the app-global + // selection, but this page owns the wallet-selection surface, so it must + // also mirror the switch into its own cache — otherwise the pill and the + // page body would disagree until the next arrival. + let (mut action, effect) = add_top_panel_with_global_nav_capturing( ui, &self.app_context, wallet_only_spec("Wallets", RootScreenType::RootScreenWalletsBalances), right_buttons, ); + self.apply_nav_effect(effect); action |= add_left_panel( ui, @@ -3330,6 +3365,165 @@ mod tests { 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, + crate::model::user_role::UserRoleCell::default(), + ) + .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)); + } + + /// 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] + 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)); + } + mod wallet_selection_linking { use super::super::{WalletsBalancesScreen, resolve_selection_from_store}; use crate::context::AppContext; @@ -3356,7 +3550,7 @@ mod tests { egui::Context::default(), app_kv, secret_store, - Arc::new(std::sync::atomic::AtomicBool::new(false)), + crate::model::user_role::UserRoleCell::default(), ) .expect("AppContext") } 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}" + ); + } +} diff --git a/src/wallet_backend/dashpay.rs b/src/wallet_backend/dashpay.rs index 01ff1e5b6..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,23 +872,110 @@ 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> { + self.put_marker(owner, KV_PREFIX_DECLINED, counterparty_id) + } + + /// 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 declined request from a + /// listing. + 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 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. 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(KV_PREFIX_REJECTED, counterparty_id); + 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(prefix, 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. @@ -1028,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 @@ -1043,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)) @@ -1274,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(); @@ -1315,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" ); @@ -1331,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 ); } @@ -1417,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(); @@ -1445,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] @@ -1536,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); @@ -1547,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); } @@ -1580,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() { @@ -1596,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), @@ -1605,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(); @@ -1615,7 +1848,7 @@ mod tests { assert!(kv_contains( &kv, &owner_a, - KV_PREFIX_REJECTED, + KV_PREFIX_DECLINED, &counterparty )); @@ -1625,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" ); @@ -1895,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), @@ -1922,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(); @@ -1944,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 @@ -1956,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), @@ -1971,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(); @@ -2010,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..27cf0d1d6 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; @@ -192,7 +193,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 +254,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 +311,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 +411,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(), @@ -689,7 +694,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) @@ -746,7 +751,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" 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/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. diff --git a/tests/kittest/migration_banner.rs b/tests/kittest/migration_banner.rs index ff6c444c5..eafffa977 100644 --- a/tests/kittest/migration_banner.rs +++ b/tests/kittest/migration_banner.rs @@ -5,7 +5,10 @@ //! 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_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; use dash_evo_tool::ui::components::MessageBanner; @@ -41,6 +44,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, @@ -150,3 +154,61 @@ 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", + ); +} + +/// 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), + ); +} 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) } 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 {